diff --git a/Makefile b/Makefile index f00b68f08..fbeadf840 100644 --- a/Makefile +++ b/Makefile @@ -172,3 +172,12 @@ ci-pending-migrations: ci--meta-debug install: web-install website-install poetry install + +dev-reset: + dropdb -U postgres -h localhost authentik + createdb -U postgres -h localhost authentik + redis-cli -n 0 flushall + redis-cli -n 1 flushall + redis-cli -n 2 flushall + redis-cli -n 3 flushall + make migrate diff --git a/authentik/admin/tests/test_api.py b/authentik/admin/tests/test_api.py index cc42ae022..f24c1f99e 100644 --- a/authentik/admin/tests/test_api.py +++ b/authentik/admin/tests/test_api.py @@ -1,11 +1,11 @@ """test admin api""" from json import loads -from django.apps import apps from django.test import TestCase from django.urls import reverse from authentik import __version__ +from authentik.blueprints.tests import reconcile_app from authentik.core.models import Group, User from authentik.core.tasks import clean_expired_models from authentik.events.monitored_tasks import TaskResultStatus @@ -93,8 +93,8 @@ class TestAdminAPI(TestCase): response = self.client.get(reverse("authentik_api:apps-list")) self.assertEqual(response.status_code, 200) + @reconcile_app("authentik_outposts") def test_system(self): """Test system API""" - apps.get_app_config("authentik_outposts").reconcile_embedded_outpost() response = self.client.get(reverse("authentik_api:admin_system")) self.assertEqual(response.status_code, 200) diff --git a/authentik/api/tests/test_auth.py b/authentik/api/tests/test_auth.py index 73f76c1a4..388408f5b 100644 --- a/authentik/api/tests/test_auth.py +++ b/authentik/api/tests/test_auth.py @@ -1,13 +1,13 @@ """Test API Authentication""" from base64 import b64encode -from django.apps import apps from django.conf import settings from django.test import TestCase from guardian.shortcuts import get_anonymous_user from rest_framework.exceptions import AuthenticationFailed from authentik.api.authentication import bearer_auth +from authentik.blueprints.tests import reconcile_app from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents from authentik.core.tests.utils import create_test_flow from authentik.lib.generators import generate_id @@ -42,9 +42,11 @@ class TestAPIAuth(TestCase): def test_managed_outpost(self): """Test managed outpost""" with self.assertRaises(AuthenticationFailed): - user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode()) + bearer_auth(f"Bearer {settings.SECRET_KEY}".encode()) - apps.get_app_config("authentik_outposts").reconcile_embedded_outpost() + @reconcile_app("authentik_outposts") + def test_managed_outpost_success(self): + """Test managed outpost""" user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode()) self.assertEqual(user.attributes[USER_ATTRIBUTE_SA], True) diff --git a/authentik/blueprints/__init__.py b/authentik/blueprints/__init__.py index ab442d844..e69de29bb 100644 --- a/authentik/blueprints/__init__.py +++ b/authentik/blueprints/__init__.py @@ -1,23 +0,0 @@ -"""Blueprint helpers""" -from functools import wraps -from typing import Callable - - -def apply_blueprint(*files: str): - """Apply blueprint before test""" - - from authentik.blueprints.v1.importer import Importer - - def wrapper_outer(func: Callable): - """Apply blueprint before test""" - - @wraps(func) - def wrapper(*args, **kwargs): - for file in files: - with open(file, "r+", encoding="utf-8") as _file: - Importer(_file.read()).apply() - return func(*args, **kwargs) - - return wrapper - - return wrapper_outer diff --git a/authentik/blueprints/api.py b/authentik/blueprints/api.py index bcfe9f311..0a3345d13 100644 --- a/authentik/blueprints/api.py +++ b/authentik/blueprints/api.py @@ -1,17 +1,20 @@ """Serializer mixin for managed models""" -from glob import glob +from dataclasses import asdict -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import extend_schema, inline_serializer from rest_framework.decorators import action -from rest_framework.fields import CharField +from rest_framework.fields import CharField, DateTimeField, JSONField from rest_framework.permissions import IsAdminUser from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ListSerializer, ModelSerializer from rest_framework.viewsets import ModelViewSet +from authentik.api.decorators import permission_required from authentik.blueprints.models import BlueprintInstance -from authentik.lib.config import CONFIG +from authentik.blueprints.v1.tasks import BlueprintFile, apply_blueprint, blueprints_find +from authentik.core.api.used_by import UsedByMixin +from authentik.core.api.utils import PassiveSerializer class ManagedSerializer: @@ -20,6 +23,13 @@ class ManagedSerializer: managed = CharField(read_only=True, allow_null=True) +class MetadataSerializer(PassiveSerializer): + """Serializer for blueprint metadata""" + + name = CharField() + labels = JSONField() + + class BlueprintInstanceSerializer(ModelSerializer): """Info about a single blueprint instance file""" @@ -36,15 +46,18 @@ class BlueprintInstanceSerializer(ModelSerializer): "status", "enabled", "managed_models", + "metadata", ] extra_kwargs = { + "status": {"read_only": True}, "last_applied": {"read_only": True}, "last_applied_hash": {"read_only": True}, "managed_models": {"read_only": True}, + "metadata": {"read_only": True}, } -class BlueprintInstanceViewSet(ModelViewSet): +class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet): """Blueprint instances""" permission_classes = [IsAdminUser] @@ -53,12 +66,37 @@ class BlueprintInstanceViewSet(ModelViewSet): search_fields = ["name", "path"] filterset_fields = ["name", "path"] - @extend_schema(responses={200: ListSerializer(child=CharField())}) + @extend_schema( + responses={ + 200: ListSerializer( + child=inline_serializer( + "BlueprintFile", + fields={ + "path": CharField(), + "last_m": DateTimeField(), + "hash": CharField(), + "meta": MetadataSerializer(required=False, read_only=True), + }, + ) + ) + } + ) @action(detail=False, pagination_class=None, filter_backends=[]) def available(self, request: Request) -> Response: """Get blueprints""" - files = [] - for folder in CONFIG.y("blueprint_locations"): - for file in glob(f"{folder}/**", recursive=True): - files.append(file) - return Response(files) + files: list[BlueprintFile] = blueprints_find.delay().get() + return Response([asdict(file) for file in files]) + + @permission_required("authentik_blueprints.view_blueprintinstance") + @extend_schema( + request=None, + responses={ + 200: BlueprintInstanceSerializer(), + }, + ) + @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"]) + def apply(self, request: Request, *args, **kwargs) -> Response: + """Apply a blueprint""" + blueprint = self.get_object() + apply_blueprint.delay(str(blueprint.pk)).get() + return self.retrieve(request, *args, **kwargs) diff --git a/authentik/blueprints/management/commands/apply_blueprint.py b/authentik/blueprints/management/commands/apply_blueprint.py index 9f75a5482..d21ecf571 100644 --- a/authentik/blueprints/management/commands/apply_blueprint.py +++ b/authentik/blueprints/management/commands/apply_blueprint.py @@ -1,10 +1,13 @@ """Apply blueprint from commandline""" from django.core.management.base import BaseCommand, no_translations +from structlog.stdlib import get_logger from authentik.blueprints.v1.importer import Importer +LOGGER = get_logger() -class Command(BaseCommand): # pragma: no cover + +class Command(BaseCommand): """Apply blueprint from commandline""" @no_translations @@ -15,7 +18,9 @@ class Command(BaseCommand): # pragma: no cover importer = Importer(blueprint_file.read()) valid, logs = importer.validate() if not valid: - raise ValueError(f"blueprint invalid: {logs}") + for log in logs: + LOGGER.debug(**log) + raise ValueError("blueprint invalid") importer.apply() def add_arguments(self, parser): diff --git a/authentik/blueprints/manager.py b/authentik/blueprints/manager.py index aa65cbf7b..82bcd22fd 100644 --- a/authentik/blueprints/manager.py +++ b/authentik/blueprints/manager.py @@ -4,7 +4,7 @@ from inspect import ismethod from django.apps import AppConfig from django.db import DatabaseError, InternalError, ProgrammingError -from structlog.stdlib import get_logger +from structlog.stdlib import BoundLogger, get_logger LOGGER = get_logger() @@ -12,6 +12,12 @@ LOGGER = get_logger() class ManagedAppConfig(AppConfig): """Basic reconciliation logic for apps""" + _logger: BoundLogger + + def __init__(self, app_name: str, *args, **kwargs) -> None: + super().__init__(app_name, *args, **kwargs) + self._logger = get_logger().bind(app_name=app_name) + def ready(self) -> None: self.reconcile() return super().ready() @@ -31,7 +37,8 @@ class ManagedAppConfig(AppConfig): continue name = meth_name.replace(prefix, "") try: + self._logger.debug("Starting reconciler", name=name) meth() - LOGGER.debug("Successfully reconciled", name=name) + self._logger.debug("Successfully reconciled", name=name) except (DatabaseError, ProgrammingError, InternalError) as exc: - LOGGER.debug("Failed to run reconcile", name=name, exc=exc) + self._logger.debug("Failed to run reconcile", name=name, exc=exc) diff --git a/authentik/blueprints/migrations/0001_initial.py b/authentik/blueprints/migrations/0001_initial.py index 5c5703e60..7da7395f6 100644 --- a/authentik/blueprints/migrations/0001_initial.py +++ b/authentik/blueprints/migrations/0001_initial.py @@ -4,7 +4,9 @@ from glob import glob from pathlib import Path import django.contrib.postgres.fields +from dacite import from_dict from django.apps.registry import Apps +from django.conf import settings from django.db import migrations, models from django.db.backends.base.schema import BaseDatabaseSchemaEditor from yaml import load @@ -15,24 +17,33 @@ from authentik.lib.config import CONFIG def check_blueprint_v1_file(BlueprintInstance: type["BlueprintInstance"], path: Path): """Check if blueprint should be imported""" from authentik.blueprints.models import BlueprintInstanceStatus - from authentik.blueprints.v1.common import BlueprintLoader + from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata + from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_EXAMPLE with open(path, "r", encoding="utf-8") as blueprint_file: raw_blueprint = load(blueprint_file.read(), BlueprintLoader) + metadata = raw_blueprint.get("metadata", None) version = raw_blueprint.get("version", 1) if version != 1: return blueprint_file.seek(0) instance: BlueprintInstance = BlueprintInstance.objects.filter(path=path).first() + rel_path = path.relative_to(Path(CONFIG.y("blueprints_dir"))) + meta = None + if metadata: + meta = from_dict(BlueprintMetadata, metadata) + if meta.labels.get(LABEL_AUTHENTIK_EXAMPLE, "").lower() == "true": + return if not instance: instance = BlueprintInstance( - name=path.name, + name=meta.name if meta else str(rel_path), path=str(path), context={}, status=BlueprintInstanceStatus.UNKNOWN, enabled=True, managed_models=[], last_applied_hash="", + metadata=metadata, ) instance.save() @@ -42,9 +53,8 @@ def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEdit Flow = apps.get_model("authentik_flows", "Flow") db_alias = schema_editor.connection.alias - for folder in CONFIG.y("blueprint_locations"): - for file in glob(f"{folder}/**/*.yaml", recursive=True): - check_blueprint_v1_file(BlueprintInstance, Path(file)) + for file in glob(f"{CONFIG.y('blueprints_dir')}/**/*.yaml", recursive=True): + check_blueprint_v1_file(BlueprintInstance, Path(file)) for blueprint in BlueprintInstance.objects.using(db_alias).all(): # If we already have flows (and we should always run before flow migrations) @@ -86,8 +96,9 @@ class Migration(migrations.Migration): ), ), ("name", models.TextField()), + ("metadata", models.JSONField(default=dict)), ("path", models.TextField()), - ("context", models.JSONField()), + ("context", models.JSONField(default=dict)), ("last_applied", models.DateTimeField(auto_now=True)), ("last_applied_hash", models.TextField()), ( @@ -106,7 +117,7 @@ class Migration(migrations.Migration): ( "managed_models", django.contrib.postgres.fields.ArrayField( - base_field=models.TextField(), size=None + base_field=models.TextField(), default=list, size=None ), ), ], diff --git a/authentik/blueprints/models.py b/authentik/blueprints/models.py index d0aba0659..e76e51c29 100644 --- a/authentik/blueprints/models.py +++ b/authentik/blueprints/models.py @@ -49,13 +49,14 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel): instance_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) name = models.TextField() + metadata = models.JSONField(default=dict) path = models.TextField() - context = models.JSONField() + context = models.JSONField(default=dict) last_applied = models.DateTimeField(auto_now=True) last_applied_hash = models.TextField() status = models.TextField(choices=BlueprintInstanceStatus.choices) enabled = models.BooleanField(default=True) - managed_models = ArrayField(models.TextField()) + managed_models = ArrayField(models.TextField(), default=list) @property def serializer(self) -> Serializer: diff --git a/authentik/blueprints/tests/__init__.py b/authentik/blueprints/tests/__init__.py index e69de29bb..29bd87294 100644 --- a/authentik/blueprints/tests/__init__.py +++ b/authentik/blueprints/tests/__init__.py @@ -0,0 +1,45 @@ +"""Blueprint helpers""" +from functools import wraps +from typing import Callable + +from django.apps import apps + +from authentik.blueprints.manager import ManagedAppConfig + + +def apply_blueprint(*files: str): + """Apply blueprint before test""" + + from authentik.blueprints.v1.importer import Importer + + def wrapper_outer(func: Callable): + """Apply blueprint before test""" + + @wraps(func) + def wrapper(*args, **kwargs): + for file in files: + with open(file, "r+", encoding="utf-8") as _file: + Importer(_file.read()).apply() + return func(*args, **kwargs) + + return wrapper + + return wrapper_outer + + +def reconcile_app(app_name: str): + """Re-reconcile AppConfig methods""" + + def wrapper_outer(func: Callable): + """Re-reconcile AppConfig methods""" + + @wraps(func) + def wrapper(*args, **kwargs): + config = apps.get_app_config(app_name) + if isinstance(config, ManagedAppConfig): + config.reconcile() + return func(*args, **kwargs) + + return wrapper + + return wrapper_outer diff --git a/authentik/blueprints/tests/test_bundled.py b/authentik/blueprints/tests/test_bundled.py index 60bd5fe3c..21e7e9eaa 100644 --- a/authentik/blueprints/tests/test_bundled.py +++ b/authentik/blueprints/tests/test_bundled.py @@ -6,12 +6,19 @@ from typing import Callable from django.test import TransactionTestCase from django.utils.text import slugify +from authentik.blueprints.tests import apply_blueprint from authentik.blueprints.v1.importer import Importer +from authentik.tenants.models import Tenant class TestBundled(TransactionTestCase): """Empty class, test methods are added dynamically""" + @apply_blueprint("blueprints/default/90-default-tenant.yaml") + def test_decorator_static(self): + """Test @apply_blueprint decorator""" + self.assertTrue(Tenant.objects.filter(domain="authentik-default").exists()) + def blueprint_tester(file_name: str) -> Callable: """This is used instead of subTest for better visibility""" diff --git a/authentik/blueprints/tests/test_transport.py b/authentik/blueprints/tests/test_v1.py similarity index 88% rename from authentik/blueprints/tests/test_transport.py rename to authentik/blueprints/tests/test_v1.py index 01779e5ae..a52ad8835 100644 --- a/authentik/blueprints/tests/test_transport.py +++ b/authentik/blueprints/tests/test_v1.py @@ -1,4 +1,4 @@ -"""Test flow Transport""" +"""Test blueprints v1""" from django.test import TransactionTestCase from authentik.blueprints.v1.exporter import Exporter @@ -10,32 +10,26 @@ from authentik.policies.models import PolicyBinding from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage from authentik.stages.user_login.models import UserLoginStage -STATIC_PROMPT_EXPORT = """{ - "version": 1, - "entries": [ - { - "identifiers": { - "pk": "cb954fd4-65a5-4ad9-b1ee-180ee9559cf4" - }, - "model": "authentik_stages_prompt.prompt", - "attrs": { - "field_key": "username", - "label": "Username", - "type": "username", - "required": true, - "placeholder": "Username", - "order": 0 - } - } - ] -}""" +STATIC_PROMPT_EXPORT = """version: 1 +entries: +- identifiers: + pk: cb954fd4-65a5-4ad9-b1ee-180ee9559cf4 + model: authentik_stages_prompt.prompt + attrs: + field_key: username + label: Username + type: username + required: true + placeholder: Username + order: 0 +""" -class TestFlowTransport(TransactionTestCase): - """Test flow Transport""" +class TestBlueprintsV1(TransactionTestCase): + """Test Blueprints""" - def test_bundle_invalid_format(self): - """Test bundle with invalid format""" + def test_blueprint_invalid_format(self): + """Test blueprint with invalid format""" importer = Importer('{"version": 3}') self.assertFalse(importer.validate()[0]) importer = Importer( diff --git a/authentik/blueprints/tests/test_v1_tasks.py b/authentik/blueprints/tests/test_v1_tasks.py new file mode 100644 index 000000000..ca6a94741 --- /dev/null +++ b/authentik/blueprints/tests/test_v1_tasks.py @@ -0,0 +1,140 @@ +"""Test blueprints v1 tasks""" +from tempfile import NamedTemporaryFile, mkdtemp + +from django.test import TransactionTestCase +from yaml import dump + +from authentik.blueprints.models import BlueprintInstance, BlueprintInstanceStatus +from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_discover, blueprints_find +from authentik.lib.config import CONFIG +from authentik.lib.generators import generate_id + +TMP = mkdtemp("authentik-blueprints") + + +class TestBlueprintsV1Tasks(TransactionTestCase): + """Test Blueprints v1 Tasks""" + + @CONFIG.patch("blueprints_dir", TMP) + def test_invalid_file_syntax(self): + """Test syntactically invalid file""" + with NamedTemporaryFile(suffix=".yaml", dir=TMP) as file: + file.write(b"{") + file.flush() + blueprints = blueprints_find() + self.assertEqual(blueprints, []) + + @CONFIG.patch("blueprints_dir", TMP) + def test_invalid_file_version(self): + """Test invalid file""" + with NamedTemporaryFile(suffix=".yaml", dir=TMP) as file: + file.write(b"version: 2") + file.flush() + blueprints = blueprints_find() + self.assertEqual(blueprints, []) + + @CONFIG.patch("blueprints_dir", TMP) + def test_valid(self): + """Test valid file""" + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + file.write( + dump( + { + "version": 1, + "entries": [], + } + ) + ) + file.flush() + blueprints_discover() # pylint: disable=no-value-for-parameter + self.assertEqual( + BlueprintInstance.objects.first().last_applied_hash, + ( + "e52bb445b03cd36057258dc9f0ce0fbed8278498ee1470e45315293e5f026d1b" + "d1f9b3526871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522" + ), + ) + self.assertEqual(BlueprintInstance.objects.first().metadata, {}) + + @CONFIG.patch("blueprints_dir", TMP) + def test_valid_updated(self): + """Test valid file""" + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + file.write( + dump( + { + "version": 1, + "entries": [], + } + ) + ) + file.flush() + blueprints_discover() # pylint: disable=no-value-for-parameter + self.assertEqual( + BlueprintInstance.objects.first().last_applied_hash, + ( + "e52bb445b03cd36057258dc9f0ce0fbed8278498ee1470e45315293e5f026d1b" + "d1f9b3526871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522" + ), + ) + self.assertEqual(BlueprintInstance.objects.first().metadata, {}) + file.write( + dump( + { + "version": 1, + "entries": [], + "metadata": { + "name": "foo", + }, + } + ) + ) + file.flush() + blueprints_discover() # pylint: disable=no-value-for-parameter + self.assertEqual( + BlueprintInstance.objects.first().last_applied_hash, + ( + "fc62fea96067da8592bdf90927246d0ca150b045447df93b0652a0e20a8bc327" + "681510b5db37ea98759c61f9a98dd2381f46a3b5a2da69dfb45158897f14e824" + ), + ) + self.assertEqual( + BlueprintInstance.objects.first().metadata, + { + "name": "foo", + "labels": {}, + }, + ) + + @CONFIG.patch("blueprints_dir", TMP) + def test_valid_disabled(self): + """Test valid file""" + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + file.write( + dump( + { + "version": 1, + "entries": [], + } + ) + ) + file.flush() + instance: BlueprintInstance = BlueprintInstance.objects.create( + name=generate_id(), + path=file.name, + enabled=False, + status=BlueprintInstanceStatus.UNKNOWN, + ) + instance.refresh_from_db() + self.assertEqual(instance.last_applied_hash, "") + self.assertEqual( + instance.status, + BlueprintInstanceStatus.UNKNOWN, + ) + apply_blueprint(instance.pk) # pylint: disable=no-value-for-parameter + instance.refresh_from_db() + self.assertEqual(instance.last_applied_hash, "") + self.assertEqual( + instance.status, + BlueprintInstanceStatus.UNKNOWN, + ) diff --git a/authentik/blueprints/v1/common.py b/authentik/blueprints/v1/common.py index c1f29ef46..a3b06f6fa 100644 --- a/authentik/blueprints/v1/common.py +++ b/authentik/blueprints/v1/common.py @@ -13,6 +13,7 @@ from yaml import SafeDumper, SafeLoader, ScalarNode, SequenceNode from authentik.lib.models import SerializerModel from authentik.lib.sentry import SentryIgnoredException +from authentik.policies.models import PolicyBindingModel def get_attrs(obj: SerializerModel) -> dict[str, Any]: @@ -83,6 +84,14 @@ class BlueprintEntry: return self.tag_resolver(self.identifiers, blueprint) +@dataclass +class BlueprintMetadata: + """Optional blueprint metadata""" + + name: str + labels: dict[str, str] = field(default_factory=dict) + + @dataclass class Blueprint: """Dataclass used for a full export""" @@ -90,6 +99,8 @@ class Blueprint: version: int = field(default=1) entries: list[BlueprintEntry] = field(default_factory=list) + metadata: Optional[BlueprintMetadata] = field(default=None) + class YAMLTag: """Base class for all YAML Tags""" @@ -112,6 +123,13 @@ class KeyOf(YAMLTag): def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: for _entry in blueprint.entries: if _entry.id == self.id_from and _entry._instance: + # Special handling for PolicyBindingModels, as they'll have a different PK + # which is used when creating policy bindings + if ( + isinstance(_entry._instance, PolicyBindingModel) + and entry.model.lower() == "authentik_policies.policybinding" + ): + return _entry._instance.pbm_uuid return _entry._instance.pk raise ValueError( f"KeyOf: failed to find entry with `id` of `{self.id_from}` and a model instance" diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py index 56d15a548..5a7ac8a1c 100644 --- a/authentik/blueprints/v1/importer.py +++ b/authentik/blueprints/v1/importer.py @@ -74,6 +74,11 @@ class Importer: except DaciteError as exc: raise EntryInvalidError from exc + @property + def blueprint(self) -> Blueprint: + """Get imported blueprint""" + return self.__import + def __update_pks_for_attrs(self, attrs: dict[str, Any]) -> dict[str, Any]: """Replace any value if it is a known primary key of an other object""" @@ -190,7 +195,7 @@ class Importer: try: serializer = self._validate_single(entry) except EntryInvalidError as exc: - self.logger.warning("entry not valid", entry=entry, error=exc) + self.logger.warning("entry invalid", entry=entry, error=exc) return False model = serializer.save() @@ -215,5 +220,7 @@ class Importer: successful = self._apply_models() if not successful: self.logger.debug("blueprint validation failed") + for log in logs: + self.logger.debug(**log) self.__import = orig_import return successful, logs diff --git a/authentik/blueprints/v1/labels.py b/authentik/blueprints/v1/labels.py new file mode 100644 index 000000000..4667bc7ca --- /dev/null +++ b/authentik/blueprints/v1/labels.py @@ -0,0 +1,4 @@ +"""Blueprint labels""" + +LABEL_AUTHENTIK_SYSTEM = "blueprints.goauthentik.io/system" +LABEL_AUTHENTIK_EXAMPLE = "blueprints.goauthentik.io/example" diff --git a/authentik/blueprints/v1/tasks.py b/authentik/blueprints/v1/tasks.py index e3120afd3..16ca31f2d 100644 --- a/authentik/blueprints/v1/tasks.py +++ b/authentik/blueprints/v1/tasks.py @@ -1,14 +1,21 @@ """v1 blueprints tasks""" +from dataclasses import asdict, dataclass, field from glob import glob from hashlib import sha512 from pathlib import Path +from typing import Optional +from dacite import from_dict from django.db import DatabaseError, InternalError, ProgrammingError +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ from yaml import load +from yaml.error import YAMLError from authentik.blueprints.models import BlueprintInstance, BlueprintInstanceStatus -from authentik.blueprints.v1.common import BlueprintLoader +from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata from authentik.blueprints.v1.importer import Importer +from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_EXAMPLE from authentik.events.monitored_tasks import ( MonitoredTask, TaskResult, @@ -19,41 +26,85 @@ from authentik.lib.config import CONFIG from authentik.root.celery import CELERY_APP +@dataclass +class BlueprintFile: + """Basic info about a blueprint file""" + + path: str + version: int + hash: str + last_m: int + meta: Optional[BlueprintMetadata] = field(default=None) + + @CELERY_APP.task( throws=(DatabaseError, ProgrammingError, InternalError), ) +def blueprints_find(): + """Find blueprints and return valid ones""" + blueprints = [] + for file in glob(f"{CONFIG.y('blueprints_dir')}/**/*.yaml", recursive=True): + path = Path(file) + with open(path, "r", encoding="utf-8") as blueprint_file: + try: + raw_blueprint = load(blueprint_file.read(), BlueprintLoader) + except YAMLError: + raw_blueprint = None + if not raw_blueprint: + continue + metadata = raw_blueprint.get("metadata", None) + version = raw_blueprint.get("version", 1) + if version != 1: + continue + file_hash = sha512(path.read_bytes()).hexdigest() + blueprint = BlueprintFile(str(path), version, file_hash, path.stat().st_mtime) + blueprint.meta = from_dict(BlueprintMetadata, metadata) if metadata else None + if ( + blueprint.meta + and blueprint.meta.labels.get(LABEL_AUTHENTIK_EXAMPLE, "").lower() == "true" + ): + continue + blueprints.append(blueprint) + return blueprints + + +@CELERY_APP.task( + throws=(DatabaseError, ProgrammingError, InternalError), base=MonitoredTask, bind=True +) @prefill_task -def blueprints_discover(): +def blueprints_discover(self: MonitoredTask): """Find blueprints and check if they need to be created in the database""" - for folder in CONFIG.y("blueprint_locations"): - for file in glob(f"{folder}/**/*.yaml", recursive=True): - check_blueprint_v1_file(Path(file)) + count = 0 + for blueprint in blueprints_find(): + check_blueprint_v1_file(blueprint) + count += 1 + self.set_status( + TaskResult( + TaskResultStatus.SUCCESSFUL, + messages=[_("Successfully imported %(count)d files." % {"count": count})], + ) + ) -def check_blueprint_v1_file(path: Path): +def check_blueprint_v1_file(blueprint: BlueprintFile): """Check if blueprint should be imported""" - with open(path, "r", encoding="utf-8") as blueprint_file: - raw_blueprint = load(blueprint_file.read(), BlueprintLoader) - version = raw_blueprint.get("version", 1) - if version != 1: - return - blueprint_file.seek(0) - file_hash = sha512(path.read_bytes()).hexdigest() - instance: BlueprintInstance = BlueprintInstance.objects.filter(path=path).first() + rel_path = Path(blueprint.path).relative_to(Path(CONFIG.y("blueprints_dir"))) + instance: BlueprintInstance = BlueprintInstance.objects.filter(path=blueprint.path).first() if not instance: instance = BlueprintInstance( - name=path.name, - path=str(path), + name=blueprint.meta.name if blueprint.meta else str(rel_path), + path=blueprint.path, context={}, status=BlueprintInstanceStatus.UNKNOWN, enabled=True, managed_models=[], + metadata={}, ) instance.save() - if instance.last_applied_hash != file_hash: - apply_blueprint.delay(instance.pk.hex) - instance.last_applied_hash = file_hash + if instance.last_applied_hash != blueprint.hash: + instance.metadata = asdict(blueprint.meta) if blueprint.meta else {} instance.save() + apply_blueprint.delay(instance.pk.hex) @CELERY_APP.task( @@ -62,25 +113,33 @@ def check_blueprint_v1_file(path: Path): ) def apply_blueprint(self: MonitoredTask, instance_pk: str): """Apply single blueprint""" + self.set_uid(instance_pk) self.save_on_success = False try: instance: BlueprintInstance = BlueprintInstance.objects.filter(pk=instance_pk).first() if not instance or not instance.enabled: return + file_hash = sha512(Path(instance.path).read_bytes()).hexdigest() with open(instance.path, "r", encoding="utf-8") as blueprint_file: importer = Importer(blueprint_file.read()) - valid, logs = importer.validate() - if not valid: - instance.status = BlueprintInstanceStatus.ERROR - instance.save() - self.set_status(TaskResult(TaskResultStatus.ERROR, [x["event"] for x in logs])) - return - applied = importer.apply() - if not applied: - instance.status = BlueprintInstanceStatus.ERROR - instance.save() - self.set_status(TaskResult(TaskResultStatus.ERROR, "Failed to apply")) - except (DatabaseError, ProgrammingError, InternalError) as exc: + valid, logs = importer.validate() + if not valid: + instance.status = BlueprintInstanceStatus.ERROR + instance.save() + self.set_status(TaskResult(TaskResultStatus.ERROR, [x["event"] for x in logs])) + return + applied = importer.apply() + if not applied: + instance.status = BlueprintInstanceStatus.ERROR + instance.save() + self.set_status(TaskResult(TaskResultStatus.ERROR, "Failed to apply")) + return + instance.status = BlueprintInstanceStatus.SUCCESSFUL + instance.last_applied_hash = file_hash + instance.last_applied = now() + instance.save() + self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL)) + except (DatabaseError, ProgrammingError, InternalError, IOError) as exc: instance.status = BlueprintInstanceStatus.ERROR instance.save() self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) diff --git a/authentik/core/management/commands/bootstrap_tasks.py b/authentik/core/management/commands/bootstrap_tasks.py index 74badb61e..e57f82258 100644 --- a/authentik/core/management/commands/bootstrap_tasks.py +++ b/authentik/core/management/commands/bootstrap_tasks.py @@ -4,7 +4,7 @@ from django.core.management.base import BaseCommand from authentik.root.celery import _get_startup_tasks -class Command(BaseCommand): # pragma: no cover +class Command(BaseCommand): """Run bootstrap tasks to ensure certain objects are created""" def handle(self, **options): diff --git a/authentik/core/management/commands/repair_permissions.py b/authentik/core/management/commands/repair_permissions.py index 10dc9423a..242aef45a 100644 --- a/authentik/core/management/commands/repair_permissions.py +++ b/authentik/core/management/commands/repair_permissions.py @@ -5,7 +5,7 @@ from django.core.management.base import BaseCommand, no_translations from guardian.management import create_anonymous_user -class Command(BaseCommand): # pragma: no cover +class Command(BaseCommand): """Repair missing permissions""" @no_translations diff --git a/authentik/core/management/commands/shell.py b/authentik/core/management/commands/shell.py index b3d85cfae..f28357cd4 100644 --- a/authentik/core/management/commands/shell.py +++ b/authentik/core/management/commands/shell.py @@ -22,7 +22,7 @@ BANNER_TEXT = """### authentik shell ({authentik}) ) -class Command(BaseCommand): # pragma: no cover +class Command(BaseCommand): """Start the Django shell with all authentik models already imported""" django_models = {} diff --git a/authentik/crypto/apps.py b/authentik/crypto/apps.py index 119067c4f..3da92b464 100644 --- a/authentik/crypto/apps.py +++ b/authentik/crypto/apps.py @@ -3,6 +3,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Optional from authentik.blueprints.manager import ManagedAppConfig +from authentik.lib.generators import generate_id if TYPE_CHECKING: from authentik.crypto.models import CertificateKeyPair @@ -53,3 +54,19 @@ class AuthentikCryptoConfig(ManagedAppConfig): now = datetime.now() if now < cert.certificate.not_valid_before or now > cert.certificate.not_valid_after: self._create_update_cert(cert) + + def reconcile_self_signed(self): + """Create self-signed keypair""" + from authentik.crypto.builder import CertificateBuilder + from authentik.crypto.models import CertificateKeyPair + + name = "authentik Self-signed Certificate" + if CertificateKeyPair.objects.filter(name=name).exists(): + return + builder = CertificateBuilder() + builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"]) + CertificateKeyPair.objects.create( + name="authentik Self-signed Certificate", + certificate_data=builder.certificate, + key_data=builder.private_key, + ) diff --git a/authentik/crypto/migrations/0002_create_self_signed_kp.py b/authentik/crypto/migrations/0002_create_self_signed_kp.py index 6ce147149..fa96a508b 100644 --- a/authentik/crypto/migrations/0002_create_self_signed_kp.py +++ b/authentik/crypto/migrations/0002_create_self_signed_kp.py @@ -5,24 +5,10 @@ from django.db import migrations from authentik.lib.generators import generate_id -def create_self_signed(apps, schema_editor): - CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair") - db_alias = schema_editor.connection.alias - from authentik.crypto.builder import CertificateBuilder - - builder = CertificateBuilder() - builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"]) - CertificateKeyPair.objects.using(db_alias).create( - name="authentik Self-signed Certificate", - certificate_data=builder.certificate, - key_data=builder.private_key, - ) - - class Migration(migrations.Migration): dependencies = [ ("authentik_crypto", "0001_initial"), ] - operations = [migrations.RunPython(create_self_signed)] + operations = [] diff --git a/authentik/flows/management/commands/benchmark.py b/authentik/flows/management/commands/benchmark.py index aeb14b57d..b02f7519f 100644 --- a/authentik/flows/management/commands/benchmark.py +++ b/authentik/flows/management/commands/benchmark.py @@ -48,7 +48,7 @@ class FlowPlanProcess(PROCESS_CLASS): # pragma: no cover self.return_dict[self.index] = diffs -class Command(BaseCommand): # pragma: no cover +class Command(BaseCommand): """Benchmark authentik""" def add_arguments(self, parser): diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml index c65408424..78527e8e9 100644 --- a/authentik/lib/default.yml +++ b/authentik/lib/default.yml @@ -79,5 +79,4 @@ cert_discovery_dir: /certs default_token_length: 128 impersonation: true -blueprint_locations: - - /blueprints +blueprints_dir: /blueprints diff --git a/authentik/outposts/tests/test_controller_docker.py b/authentik/outposts/tests/test_controller_docker.py index d45e02b70..1fd00d9c9 100644 --- a/authentik/outposts/tests/test_controller_docker.py +++ b/authentik/outposts/tests/test_controller_docker.py @@ -1,8 +1,8 @@ """Docker controller tests""" -from django.apps import apps from django.test import TestCase from docker.models.containers import Container +from authentik.blueprints.tests import reconcile_app from authentik.outposts.apps import MANAGED_OUTPOST from authentik.outposts.controllers.base import ControllerException from authentik.outposts.controllers.docker import DockerController @@ -13,13 +13,13 @@ from authentik.providers.proxy.controllers.docker import ProxyDockerController class DockerControllerTests(TestCase): """Docker controller tests""" + @reconcile_app("authentik_outposts") def setUp(self) -> None: self.outpost = Outpost.objects.create( name="test", type=OutpostType.PROXY, ) self.integration = DockerServiceConnection(name="test") - apps.get_app_config("authentik_outposts").reconcile() def test_init_managed(self): """Docker controller shouldn't do anything for managed outpost""" diff --git a/authentik/providers/oauth2/tests/test_token_cc.py b/authentik/providers/oauth2/tests/test_token_cc.py index 9707b54e0..97b191cfd 100644 --- a/authentik/providers/oauth2/tests/test_token_cc.py +++ b/authentik/providers/oauth2/tests/test_token_cc.py @@ -5,7 +5,7 @@ from django.test import RequestFactory from django.urls import reverse from jwt import decode -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint from authentik.core.models import USER_ATTRIBUTE_SA, Application, Group, Token, TokenIntents from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow from authentik.lib.generators import generate_id, generate_key diff --git a/authentik/providers/oauth2/tests/test_token_cc_jwt_source.py b/authentik/providers/oauth2/tests/test_token_cc_jwt_source.py index e41b5f0ec..77e511a9f 100644 --- a/authentik/providers/oauth2/tests/test_token_cc_jwt_source.py +++ b/authentik/providers/oauth2/tests/test_token_cc_jwt_source.py @@ -6,7 +6,7 @@ from django.test import RequestFactory from django.urls import reverse from jwt import decode -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint from authentik.core.models import Application, Group from authentik.core.tests.utils import create_test_cert, create_test_flow from authentik.lib.generators import generate_id, generate_key diff --git a/authentik/providers/oauth2/tests/test_userinfo.py b/authentik/providers/oauth2/tests/test_userinfo.py index 93849ebfc..c0342a4d7 100644 --- a/authentik/providers/oauth2/tests/test_userinfo.py +++ b/authentik/providers/oauth2/tests/test_userinfo.py @@ -4,7 +4,7 @@ from dataclasses import asdict from django.urls import reverse -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint from authentik.core.models import Application from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow from authentik.events.models import Event, EventAction diff --git a/authentik/providers/saml/tests/test_auth_n_request.py b/authentik/providers/saml/tests/test_auth_n_request.py index 44a46564b..5e682994e 100644 --- a/authentik/providers/saml/tests/test_auth_n_request.py +++ b/authentik/providers/saml/tests/test_auth_n_request.py @@ -4,7 +4,7 @@ from base64 import b64encode from django.http.request import QueryDict from django.test import RequestFactory, TestCase -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow from authentik.crypto.models import CertificateKeyPair from authentik.events.models import Event, EventAction diff --git a/authentik/providers/saml/tests/test_schema.py b/authentik/providers/saml/tests/test_schema.py index 9bb5dc6ee..49463588f 100644 --- a/authentik/providers/saml/tests/test_schema.py +++ b/authentik/providers/saml/tests/test_schema.py @@ -4,7 +4,7 @@ from base64 import b64encode from django.test import RequestFactory, TestCase from lxml import etree # nosec -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint from authentik.core.tests.utils import create_test_cert, create_test_flow from authentik.lib.tests.utils import get_request from authentik.lib.xml import lxml_from_string diff --git a/authentik/sources/ldap/tests/test_auth.py b/authentik/sources/ldap/tests/test_auth.py index 8923e3fa2..51ab96cd6 100644 --- a/authentik/sources/ldap/tests/test_auth.py +++ b/authentik/sources/ldap/tests/test_auth.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, PropertyMock, patch from django.db.models import Q from django.test import TestCase -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint from authentik.core.models import User from authentik.lib.generators import generate_key from authentik.sources.ldap.auth import LDAPBackend diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py index b9664eb04..f44e776a4 100644 --- a/authentik/sources/ldap/tests/test_sync.py +++ b/authentik/sources/ldap/tests/test_sync.py @@ -4,7 +4,7 @@ from unittest.mock import PropertyMock, patch from django.db.models import Q from django.test import TestCase -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint from authentik.core.models import Group, User from authentik.core.tests.utils import create_test_admin_user from authentik.events.models import Event, EventAction diff --git a/authentik/stages/email/management/commands/test_email.py b/authentik/stages/email/management/commands/test_email.py index 3700d53c1..eb9117980 100644 --- a/authentik/stages/email/management/commands/test_email.py +++ b/authentik/stages/email/management/commands/test_email.py @@ -8,7 +8,7 @@ from authentik.stages.email.tasks import send_mail from authentik.stages.email.utils import TemplateEmailMessage -class Command(BaseCommand): # pragma: no cover +class Command(BaseCommand): """Send a test-email with global settings""" @no_translations diff --git a/blueprints/default/0-flow-password-change.yaml b/blueprints/default/0-flow-password-change.yaml index 6060b550f..c59d73f15 100644 --- a/blueprints/default/0-flow-password-change.yaml +++ b/blueprints/default/0-flow-password-change.yaml @@ -1,3 +1,5 @@ +metadata: + name: Default - Password change flow entries: - attrs: compatibility_mode: false diff --git a/blueprints/default/10-flow-default-authentication-flow.yaml b/blueprints/default/10-flow-default-authentication-flow.yaml index 990241150..c44587e77 100644 --- a/blueprints/default/10-flow-default-authentication-flow.yaml +++ b/blueprints/default/10-flow-default-authentication-flow.yaml @@ -1,3 +1,5 @@ +metadata: + name: Default - Authentication flow entries: - attrs: cache_count: 1 diff --git a/blueprints/default/10-flow-default-invalidation-flow.yaml b/blueprints/default/10-flow-default-invalidation-flow.yaml index 6d67991b4..5c1c11580 100644 --- a/blueprints/default/10-flow-default-invalidation-flow.yaml +++ b/blueprints/default/10-flow-default-invalidation-flow.yaml @@ -1,3 +1,5 @@ +metadata: + name: Default - Invalidation flow entries: - attrs: compatibility_mode: false diff --git a/blueprints/default/20-flow-default-authenticator-static-setup.yaml b/blueprints/default/20-flow-default-authenticator-static-setup.yaml index 95cc7a229..4ac810007 100644 --- a/blueprints/default/20-flow-default-authenticator-static-setup.yaml +++ b/blueprints/default/20-flow-default-authenticator-static-setup.yaml @@ -1,3 +1,5 @@ +metadata: + name: Default - Static MFA setup flow entries: - attrs: compatibility_mode: false diff --git a/blueprints/default/20-flow-default-authenticator-totp-setup.yaml b/blueprints/default/20-flow-default-authenticator-totp-setup.yaml index 80863fabb..017486361 100644 --- a/blueprints/default/20-flow-default-authenticator-totp-setup.yaml +++ b/blueprints/default/20-flow-default-authenticator-totp-setup.yaml @@ -1,3 +1,5 @@ +metadata: + name: Default - TOTP MFA setup flow entries: - attrs: compatibility_mode: false diff --git a/blueprints/default/20-flow-default-authenticator-webauthn-setup.yaml b/blueprints/default/20-flow-default-authenticator-webauthn-setup.yaml index d27ac46e7..2b5962de5 100644 --- a/blueprints/default/20-flow-default-authenticator-webauthn-setup.yaml +++ b/blueprints/default/20-flow-default-authenticator-webauthn-setup.yaml @@ -1,3 +1,5 @@ +metadata: + name: Default - WebAuthn MFA setup flow entries: - attrs: compatibility_mode: false diff --git a/blueprints/default/20-flow-default-provider-authorization-explicit-consent.yaml b/blueprints/default/20-flow-default-provider-authorization-explicit-consent.yaml index 87e3fa59b..681bbd7cb 100644 --- a/blueprints/default/20-flow-default-provider-authorization-explicit-consent.yaml +++ b/blueprints/default/20-flow-default-provider-authorization-explicit-consent.yaml @@ -1,3 +1,5 @@ +metadata: + name: Default - Provider authorization flow (explicit consent) entries: - attrs: compatibility_mode: false diff --git a/blueprints/default/20-flow-default-provider-authorization-implicit-consent.yaml b/blueprints/default/20-flow-default-provider-authorization-implicit-consent.yaml index 080010b52..94ac91645 100644 --- a/blueprints/default/20-flow-default-provider-authorization-implicit-consent.yaml +++ b/blueprints/default/20-flow-default-provider-authorization-implicit-consent.yaml @@ -1,3 +1,5 @@ +metadata: + name: Default - Provider authorization flow (implicit consent) entries: - attrs: compatibility_mode: false diff --git a/blueprints/default/20-flow-default-source-authentication.yaml b/blueprints/default/20-flow-default-source-authentication.yaml index 198492532..f5437914a 100644 --- a/blueprints/default/20-flow-default-source-authentication.yaml +++ b/blueprints/default/20-flow-default-source-authentication.yaml @@ -1,3 +1,5 @@ +metadata: + name: Default - Source authentication flow entries: - attrs: compatibility_mode: false diff --git a/blueprints/default/20-flow-default-source-enrollment.yaml b/blueprints/default/20-flow-default-source-enrollment.yaml index d226dce39..998468e8c 100644 --- a/blueprints/default/20-flow-default-source-enrollment.yaml +++ b/blueprints/default/20-flow-default-source-enrollment.yaml @@ -1,3 +1,5 @@ +metadata: + name: Default - Source enrollment flow entries: - attrs: compatibility_mode: false diff --git a/blueprints/default/20-flow-default-source-pre-authentication.yaml b/blueprints/default/20-flow-default-source-pre-authentication.yaml index b6e764184..d958b888e 100644 --- a/blueprints/default/20-flow-default-source-pre-authentication.yaml +++ b/blueprints/default/20-flow-default-source-pre-authentication.yaml @@ -1,3 +1,5 @@ +metadata: + name: Default - Source pre-authentication flow entries: - attrs: compatibility_mode: false diff --git a/blueprints/default/30-flow-default-user-settings-flow.yaml b/blueprints/default/30-flow-default-user-settings-flow.yaml index ac76eb4ac..d02a8de8a 100644 --- a/blueprints/default/30-flow-default-user-settings-flow.yaml +++ b/blueprints/default/30-flow-default-user-settings-flow.yaml @@ -1,3 +1,5 @@ +metadata: + name: Default - User settings flow entries: - attrs: compatibility_mode: false diff --git a/blueprints/default/90-default-tenant.yaml b/blueprints/default/90-default-tenant.yaml index 782d41081..e3eaf5a1f 100644 --- a/blueprints/default/90-default-tenant.yaml +++ b/blueprints/default/90-default-tenant.yaml @@ -1,3 +1,5 @@ +metadata: + name: Default - Tenant version: 1 entries: - attrs: diff --git a/blueprints/example/flows-enrollment-2-stage.yaml b/blueprints/example/flows-enrollment-2-stage.yaml index db6810fb4..117e41399 100644 --- a/blueprints/example/flows-enrollment-2-stage.yaml +++ b/blueprints/example/flows-enrollment-2-stage.yaml @@ -1,4 +1,8 @@ version: 1 +metadata: + labels: + blueprints.goauthentik.io/example: "true" + name: Example - Enrollment (2 Stage) entries: - identifiers: slug: default-enrollment-flow diff --git a/blueprints/example/flows-enrollment-email-verification.yaml b/blueprints/example/flows-enrollment-email-verification.yaml index add25827a..f6f24ecd4 100644 --- a/blueprints/example/flows-enrollment-email-verification.yaml +++ b/blueprints/example/flows-enrollment-email-verification.yaml @@ -1,4 +1,8 @@ version: 1 +metadata: + labels: + blueprints.goauthentik.io/example: "true" + name: Example - Enrollment with email verification entries: - identifiers: slug: default-enrollment-flow diff --git a/blueprints/example/flows-login-2fa.yaml b/blueprints/example/flows-login-2fa.yaml index 05a80ad8b..f86846881 100644 --- a/blueprints/example/flows-login-2fa.yaml +++ b/blueprints/example/flows-login-2fa.yaml @@ -1,4 +1,8 @@ version: 1 +metadata: + labels: + blueprints.goauthentik.io/example: "true" + name: Example - Two-factor Login entries: - identifiers: slug: default-authentication-flow diff --git a/blueprints/example/flows-login-conditional-captcha.yaml b/blueprints/example/flows-login-conditional-captcha.yaml index 7b110448f..ed24f919f 100644 --- a/blueprints/example/flows-login-conditional-captcha.yaml +++ b/blueprints/example/flows-login-conditional-captcha.yaml @@ -1,4 +1,8 @@ version: 1 +metadata: + labels: + blueprints.goauthentik.io/example: "true" + name: Example - Login with conditional Captcha entries: - identifiers: slug: default-authentication-flow diff --git a/blueprints/example/flows-recovery-email-verification.yaml b/blueprints/example/flows-recovery-email-verification.yaml index 6084d242b..041e481fd 100644 --- a/blueprints/example/flows-recovery-email-verification.yaml +++ b/blueprints/example/flows-recovery-email-verification.yaml @@ -1,4 +1,8 @@ version: 1 +metadata: + labels: + blueprints.goauthentik.io/example: "true" + name: Example - Recovery with email verification entries: - identifiers: slug: default-recovery-flow diff --git a/blueprints/example/flows-unenrollment.yaml b/blueprints/example/flows-unenrollment.yaml index c843570e1..c090ae7b9 100644 --- a/blueprints/example/flows-unenrollment.yaml +++ b/blueprints/example/flows-unenrollment.yaml @@ -1,4 +1,8 @@ version: 1 +metadata: + labels: + blueprints.goauthentik.io/example: "true" + name: Example - User deletion entries: - identifiers: slug: default-unenrollment-flow diff --git a/blueprints/system/providers-oauth2.yaml b/blueprints/system/providers-oauth2.yaml index ed5f3e4e4..0630a6721 100644 --- a/blueprints/system/providers-oauth2.yaml +++ b/blueprints/system/providers-oauth2.yaml @@ -1,4 +1,8 @@ version: 1 +metadata: + labels: + blueprints.goauthentik.io/system: "true" + name: System - OAuth2 Provider - Scopes entries: - identifiers: managed: goauthentik.io/providers/oauth2/scope-openid diff --git a/blueprints/system/providers-proxy.yaml b/blueprints/system/providers-proxy.yaml index 221eb1cd0..a07751d86 100644 --- a/blueprints/system/providers-proxy.yaml +++ b/blueprints/system/providers-proxy.yaml @@ -1,4 +1,8 @@ version: 1 +metadata: + labels: + blueprints.goauthentik.io/system: "true" + name: System - Proxy Provider - Scopes entries: - identifiers: managed: goauthentik.io/providers/proxy/scope-proxy diff --git a/blueprints/system/providers-saml.yaml b/blueprints/system/providers-saml.yaml index 60a9bddee..03f025513 100644 --- a/blueprints/system/providers-saml.yaml +++ b/blueprints/system/providers-saml.yaml @@ -1,4 +1,8 @@ version: 1 +metadata: + labels: + blueprints.goauthentik.io/system: "true" + name: System - SAML Provider - Mappings entries: - identifiers: managed: goauthentik.io/providers/saml/upn diff --git a/blueprints/system/sources-ldap.yaml b/blueprints/system/sources-ldap.yaml index 0282d9afd..f3a43f698 100644 --- a/blueprints/system/sources-ldap.yaml +++ b/blueprints/system/sources-ldap.yaml @@ -1,4 +1,8 @@ version: 1 +metadata: + labels: + blueprints.goauthentik.io/system: "true" + name: System - LDAP Source - Mappings entries: - identifiers: managed: goauthentik.io/sources/ldap/default-name diff --git a/pyproject.toml b/pyproject.toml index 74ca8f965..266cb2fb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,14 @@ force_to_top = "*" [tool.coverage.run] source = ["authentik"] relative_files = true -omit = ["*/asgi.py", "manage.py", "*/migrations/*", "*/apps.py", "website/"] +omit = [ + "*/asgi.py", + "manage.py", + "*/migrations/*", + "*/management/commands/*", + "*/apps.py", + "website/", +] [tool.coverage.report] sort = "Cover" diff --git a/schema.yml b/schema.yml index 3eda092b6..8c2de8fd6 100644 --- a/schema.yml +++ b/schema.yml @@ -6218,6 +6218,62 @@ paths: $ref: '#/components/schemas/ValidationError' '403': $ref: '#/components/schemas/GenericError' + /managed/blueprints/{instance_uuid}/apply/: + post: + operationId: managed_blueprints_apply_create + description: Apply a blueprint + parameters: + - in: path + name: instance_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Blueprint Instance. + required: true + tags: + - managed + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/BlueprintInstance' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' + /managed/blueprints/{instance_uuid}/used_by/: + get: + operationId: managed_blueprints_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: instance_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Blueprint Instance. + required: true + tags: + - managed + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' /managed/blueprints/available/: get: operationId: managed_blueprints_available_list @@ -6233,7 +6289,7 @@ paths: schema: type: array items: - type: string + $ref: '#/components/schemas/BlueprintFile' description: '' '400': $ref: '#/components/schemas/ValidationError' @@ -20862,6 +20918,25 @@ components: - POST - POST_AUTO type: string + BlueprintFile: + type: object + properties: + path: + type: string + last_m: + type: string + format: date-time + hash: + type: string + meta: + allOf: + - $ref: '#/components/schemas/Metadata' + readOnly: true + required: + - hash + - last_m + - meta + - path BlueprintInstance: type: object description: Info about a single blueprint instance file @@ -20886,7 +20961,9 @@ components: type: string readOnly: true status: - $ref: '#/components/schemas/BlueprintInstanceStatusEnum' + allOf: + - $ref: '#/components/schemas/BlueprintInstanceStatusEnum' + readOnly: true enabled: type: boolean managed_models: @@ -20894,11 +20971,15 @@ components: items: type: string readOnly: true + metadata: + type: object + additionalProperties: {} + readOnly: true required: - - context - last_applied - last_applied_hash - managed_models + - metadata - name - path - pk @@ -20916,15 +20997,11 @@ components: context: type: object additionalProperties: {} - status: - $ref: '#/components/schemas/BlueprintInstanceStatusEnum' enabled: type: boolean required: - - context - name - path - - status BlueprintInstanceStatusEnum: enum: - successful @@ -23774,6 +23851,18 @@ components: required: - challenge - name + Metadata: + type: object + description: Serializer for blueprint metadata + properties: + name: + type: string + labels: + type: object + additionalProperties: {} + required: + - labels + - name NameIdPolicyEnum: enum: - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress @@ -27808,8 +27897,6 @@ components: context: type: object additionalProperties: {} - status: - $ref: '#/components/schemas/BlueprintInstanceStatusEnum' enabled: type: boolean PatchedCaptchaStageRequest: diff --git a/scripts/generate_config.py b/scripts/generate_config.py index 4d3d474e9..da48d10c5 100644 --- a/scripts/generate_config.py +++ b/scripts/generate_config.py @@ -13,8 +13,8 @@ with open("local.env.yml", "w") as _config: }, "outposts": { "container_image_base": "ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s", - "blueprint_locations": ["./blueprints"], }, + "blueprints_dir": "./blueprints", "web": { "outpost_port_offset": 100, }, diff --git a/tests/e2e/test_flows_authenticators.py b/tests/e2e/test_flows_authenticators.py index e872136e9..4ef14a3a1 100644 --- a/tests/e2e/test_flows_authenticators.py +++ b/tests/e2e/test_flows_authenticators.py @@ -13,7 +13,7 @@ from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support.wait import WebDriverWait -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint from authentik.flows.models import Flow from authentik.stages.authenticator_static.models import AuthenticatorStaticStage from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage diff --git a/tests/e2e/test_flows_enroll.py b/tests/e2e/test_flows_enroll.py index 06c708f40..48b50975e 100644 --- a/tests/e2e/test_flows_enroll.py +++ b/tests/e2e/test_flows_enroll.py @@ -9,7 +9,7 @@ from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support.wait import WebDriverWait -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint from authentik.core.models import User from authentik.core.tests.utils import create_test_flow from authentik.flows.models import FlowDesignation, FlowStageBinding diff --git a/tests/e2e/test_flows_login.py b/tests/e2e/test_flows_login.py index 3a0db6538..dbed9c346 100644 --- a/tests/e2e/test_flows_login.py +++ b/tests/e2e/test_flows_login.py @@ -2,7 +2,7 @@ from sys import platform from unittest.case import skipUnless -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint from tests.e2e.utils import SeleniumTestCase, retry diff --git a/tests/e2e/test_flows_stage_setup.py b/tests/e2e/test_flows_stage_setup.py index e9b657a71..8672b837d 100644 --- a/tests/e2e/test_flows_stage_setup.py +++ b/tests/e2e/test_flows_stage_setup.py @@ -5,7 +5,7 @@ from unittest.case import skipUnless from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint from authentik.core.models import User from authentik.flows.models import Flow, FlowDesignation from authentik.lib.generators import generate_key diff --git a/tests/e2e/test_provider_ldap.py b/tests/e2e/test_provider_ldap.py index 1ef2a48dd..e05f4a3c6 100644 --- a/tests/e2e/test_provider_ldap.py +++ b/tests/e2e/test_provider_ldap.py @@ -10,14 +10,14 @@ from guardian.shortcuts import get_anonymous_user from ldap3 import ALL, ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE, Connection, Server from ldap3.core.exceptions import LDAPInvalidCredentialsResult -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint, reconcile_app from authentik.core.models import Application, User from authentik.events.models import Event, EventAction from authentik.flows.models import Flow from authentik.outposts.apps import MANAGED_OUTPOST from authentik.outposts.models import Outpost, OutpostConfig, OutpostType from authentik.providers.ldap.models import APIAccessMode, LDAPProvider -from tests.e2e.utils import SeleniumTestCase, reconcile_app, retry +from tests.e2e.utils import SeleniumTestCase, retry @skipUnless(platform.startswith("linux"), "requires local docker") diff --git a/tests/e2e/test_provider_oauth2_github.py b/tests/e2e/test_provider_oauth2_github.py index a5e2dd4d8..f6462c8a5 100644 --- a/tests/e2e/test_provider_oauth2_github.py +++ b/tests/e2e/test_provider_oauth2_github.py @@ -8,14 +8,14 @@ from docker.types import Healthcheck from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as ec -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint, reconcile_app from authentik.core.models import Application from authentik.flows.models import Flow from authentik.lib.generators import generate_id, generate_key from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.models import PolicyBinding from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider -from tests.e2e.utils import SeleniumTestCase, reconcile_app, retry +from tests.e2e.utils import SeleniumTestCase, retry @skipUnless(platform.startswith("linux"), "requires local docker") diff --git a/tests/e2e/test_provider_oauth2_grafana.py b/tests/e2e/test_provider_oauth2_grafana.py index 5c301bd35..e8018c5be 100644 --- a/tests/e2e/test_provider_oauth2_grafana.py +++ b/tests/e2e/test_provider_oauth2_grafana.py @@ -8,7 +8,7 @@ from docker.types import Healthcheck from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as ec -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint, reconcile_app from authentik.core.models import Application from authentik.core.tests.utils import create_test_cert from authentik.flows.models import Flow @@ -21,7 +21,7 @@ from authentik.providers.oauth2.constants import ( SCOPE_OPENID_PROFILE, ) from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping -from tests.e2e.utils import SeleniumTestCase, reconcile_app, retry +from tests.e2e.utils import SeleniumTestCase, retry @skipUnless(platform.startswith("linux"), "requires local docker") diff --git a/tests/e2e/test_provider_oauth2_oidc.py b/tests/e2e/test_provider_oauth2_oidc.py index af05eb632..f54f4e74f 100644 --- a/tests/e2e/test_provider_oauth2_oidc.py +++ b/tests/e2e/test_provider_oauth2_oidc.py @@ -10,7 +10,7 @@ from docker.types import Healthcheck from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as ec -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint, reconcile_app from authentik.core.models import Application from authentik.core.tests.utils import create_test_cert from authentik.flows.models import Flow @@ -23,7 +23,7 @@ from authentik.providers.oauth2.constants import ( SCOPE_OPENID_PROFILE, ) from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping -from tests.e2e.utils import SeleniumTestCase, reconcile_app, retry +from tests.e2e.utils import SeleniumTestCase, retry @skipUnless(platform.startswith("linux"), "requires local docker") diff --git a/tests/e2e/test_provider_oauth2_oidc_implicit.py b/tests/e2e/test_provider_oauth2_oidc_implicit.py index 19c743ebb..8aa0252bf 100644 --- a/tests/e2e/test_provider_oauth2_oidc_implicit.py +++ b/tests/e2e/test_provider_oauth2_oidc_implicit.py @@ -10,7 +10,7 @@ from docker.types import Healthcheck from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as ec -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint, reconcile_app from authentik.core.models import Application from authentik.core.tests.utils import create_test_cert from authentik.flows.models import Flow @@ -23,7 +23,7 @@ from authentik.providers.oauth2.constants import ( SCOPE_OPENID_PROFILE, ) from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping -from tests.e2e.utils import SeleniumTestCase, reconcile_app, retry +from tests.e2e.utils import SeleniumTestCase, retry @skipUnless(platform.startswith("linux"), "requires local docker") diff --git a/tests/e2e/test_provider_proxy.py b/tests/e2e/test_provider_proxy.py index 037a0c36d..c5c4111f2 100644 --- a/tests/e2e/test_provider_proxy.py +++ b/tests/e2e/test_provider_proxy.py @@ -11,13 +11,13 @@ from docker.models.containers import Container from selenium.webdriver.common.by import By from authentik import __version__ -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint, reconcile_app from authentik.core.models import Application from authentik.flows.models import Flow from authentik.outposts.models import DockerServiceConnection, Outpost, OutpostConfig, OutpostType from authentik.outposts.tasks import outpost_local_connection from authentik.providers.proxy.models import ProxyProvider -from tests.e2e.utils import SeleniumTestCase, reconcile_app, retry +from tests.e2e.utils import SeleniumTestCase, retry @skipUnless(platform.startswith("linux"), "requires local docker") diff --git a/tests/e2e/test_provider_saml.py b/tests/e2e/test_provider_saml.py index f0302bcc5..9e19c6540 100644 --- a/tests/e2e/test_provider_saml.py +++ b/tests/e2e/test_provider_saml.py @@ -10,7 +10,7 @@ from docker.types import Healthcheck from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as ec -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint, reconcile_app from authentik.core.models import Application from authentik.core.tests.utils import create_test_cert from authentik.flows.models import Flow @@ -18,7 +18,7 @@ from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.models import PolicyBinding from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider from authentik.sources.saml.processors.constants import SAML_BINDING_POST -from tests.e2e.utils import SeleniumTestCase, reconcile_app, retry +from tests.e2e.utils import SeleniumTestCase, retry @skipUnless(platform.startswith("linux"), "requires local docker") diff --git a/tests/e2e/test_source_oauth.py b/tests/e2e/test_source_oauth.py index d59302c17..24627d318 100644 --- a/tests/e2e/test_source_oauth.py +++ b/tests/e2e/test_source_oauth.py @@ -13,7 +13,7 @@ from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support.wait import WebDriverWait from yaml import safe_dump -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint from authentik.core.models import User from authentik.flows.models import Flow from authentik.lib.generators import generate_id, generate_key diff --git a/tests/e2e/test_source_saml.py b/tests/e2e/test_source_saml.py index 88625ccc8..20c74abb5 100644 --- a/tests/e2e/test_source_saml.py +++ b/tests/e2e/test_source_saml.py @@ -11,7 +11,7 @@ from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support.wait import WebDriverWait -from authentik.blueprints import apply_blueprint +from authentik.blueprints.tests import apply_blueprint from authentik.core.models import User from authentik.crypto.models import CertificateKeyPair from authentik.flows.models import Flow diff --git a/tests/e2e/utils.py b/tests/e2e/utils.py index 6fe1d824d..8b8bd8a5e 100644 --- a/tests/e2e/utils.py +++ b/tests/e2e/utils.py @@ -6,7 +6,6 @@ from os import environ, makedirs from time import sleep, time from typing import Any, Callable, Optional -from django.apps import apps from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.db import connection from django.db.migrations.loader import MigrationLoader @@ -24,7 +23,6 @@ from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.support.ui import WebDriverWait from structlog.stdlib import get_logger -from authentik.blueprints.manager import ManagedAppConfig from authentik.core.api.users import UserSerializer from authentik.core.models import User from authentik.core.tests.utils import create_test_admin_user @@ -192,24 +190,6 @@ def get_loader(): return MigrationLoader(connection) -def reconcile_app(app_name: str): - """Re-reconcile AppConfig methods""" - - def wrapper_outer(func: Callable): - """Re-reconcile AppConfig methods""" - - @wraps(func) - def wrapper(self: TransactionTestCase, *args, **kwargs): - config = apps.get_app_config(app_name) - if isinstance(config, ManagedAppConfig): - config.reconcile() - return func(self, *args, **kwargs) - - return wrapper - - return wrapper_outer - - def retry(max_retires=RETRIES, exceptions=None): """Retry test multiple times. Default to catching Selenium Timeout Exception""" diff --git a/web/src/interfaces/AdminInterface.ts b/web/src/interfaces/AdminInterface.ts index 99a47cfb6..6480a9e0e 100644 --- a/web/src/interfaces/AdminInterface.ts +++ b/web/src/interfaces/AdminInterface.ts @@ -300,6 +300,9 @@ export class AdminInterface extends LitElement { ${t`Certificates`} + + ${t`Blueprints`} + `; } diff --git a/web/src/pages/blueprints/BlueprintForm.ts b/web/src/pages/blueprints/BlueprintForm.ts new file mode 100644 index 000000000..cbc8ba80e --- /dev/null +++ b/web/src/pages/blueprints/BlueprintForm.ts @@ -0,0 +1,105 @@ +import { DEFAULT_CONFIG } from "@goauthentik/web/api/Config"; +import "@goauthentik/web/elements/CodeMirror"; +import "@goauthentik/web/elements/forms/FormGroup"; +import "@goauthentik/web/elements/forms/HorizontalFormElement"; +import { ModelForm } from "@goauthentik/web/elements/forms/ModelForm"; +import { first } from "@goauthentik/web/utils"; +import YAML from "yaml"; + +import { t } from "@lingui/macro"; + +import { TemplateResult, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { until } from "lit/directives/until.js"; + +import { BlueprintInstance, ManagedApi } from "@goauthentik/api"; + +@customElement("ak-blueprint-form") +export class BlueprintForm extends ModelForm { + loadInstance(pk: string): Promise { + return new ManagedApi(DEFAULT_CONFIG).managedBlueprintsRetrieve({ + instanceUuid: pk, + }); + } + + getSuccessMessage(): string { + if (this.instance) { + return t`Successfully updated instance.`; + } else { + return t`Successfully created instance.`; + } + } + + send = (data: BlueprintInstance): Promise => { + if (this.instance?.pk) { + return new ManagedApi(DEFAULT_CONFIG).managedBlueprintsUpdate({ + instanceUuid: this.instance.pk, + blueprintInstanceRequest: data, + }); + } else { + return new ManagedApi(DEFAULT_CONFIG).managedBlueprintsCreate({ + blueprintInstanceRequest: data, + }); + } + }; + + renderForm(): TemplateResult { + return html`
+ + + + +
+ + +
+

${t`Disabled blueprints are never applied.`}

+
+ + + + + ${t`Additional settings`} +
+ + + +

+ ${t`Configure the blueprint context, used for templating.`} +

+
+
+
+
`; + } +} diff --git a/web/src/pages/blueprints/BlueprintListPage.ts b/web/src/pages/blueprints/BlueprintListPage.ts new file mode 100644 index 000000000..1a0913b9a --- /dev/null +++ b/web/src/pages/blueprints/BlueprintListPage.ts @@ -0,0 +1,148 @@ +import { AKResponse } from "@goauthentik/web/api/Client"; +import { DEFAULT_CONFIG } from "@goauthentik/web/api/Config"; +import { uiConfig } from "@goauthentik/web/common/config"; +import { EVENT_REFRESH } from "@goauthentik/web/constants"; +import { PFColor } from "@goauthentik/web/elements/Label"; +import "@goauthentik/web/elements/buttons/ActionButton"; +import "@goauthentik/web/elements/buttons/SpinnerButton"; +import "@goauthentik/web/elements/forms/DeleteBulkForm"; +import "@goauthentik/web/elements/forms/ModalForm"; +import { TableColumn } from "@goauthentik/web/elements/table/Table"; +import { TablePage } from "@goauthentik/web/elements/table/TablePage"; +import "@goauthentik/web/pages/blueprints/BlueprintForm"; + +import { t } from "@lingui/macro"; + +import { TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { BlueprintInstance, BlueprintInstanceStatusEnum, ManagedApi } from "@goauthentik/api"; + +export function BlueprintStatus(blueprint?: BlueprintInstance): string { + if (!blueprint) return ""; + switch (blueprint.status) { + case BlueprintInstanceStatusEnum.Successful: + return t`Successful`; + case BlueprintInstanceStatusEnum.Orphaned: + return t`Orphaned`; + case BlueprintInstanceStatusEnum.Unknown: + return t`Unknown`; + case BlueprintInstanceStatusEnum.Warning: + return t`Warning`; + case BlueprintInstanceStatusEnum.Error: + return t`Error`; + } +} +@customElement("ak-blueprint-list") +export class BlueprintListPage extends TablePage { + searchEnabled(): boolean { + return true; + } + pageTitle(): string { + return t`Blueprints`; + } + pageDescription(): string { + return t`Automate and template configuration within authentik.`; + } + pageIcon(): string { + return "pf-icon pf-icon-blueprint"; + } + + checkbox = true; + + @property() + order = "name"; + + async apiEndpoint(page: number): Promise> { + return new ManagedApi(DEFAULT_CONFIG).managedBlueprintsList({ + ordering: this.order, + page: page, + pageSize: (await uiConfig()).pagination.perPage, + search: this.search || "", + }); + } + + columns(): TableColumn[] { + return [ + new TableColumn(t`Name`, "name"), + new TableColumn(t`Status`, "status"), + new TableColumn(t`Last applied`, "last_applied"), + new TableColumn(t`Enabled`, "enabled"), + new TableColumn(t`Actions`), + ]; + } + + renderToolbarSelected(): TemplateResult { + const disabled = this.selectedElements.length < 1; + return html` { + return [{ key: t`Name`, value: item.name }]; + }} + .usedBy=${(item: BlueprintInstance) => { + return new ManagedApi(DEFAULT_CONFIG).managedBlueprintsUsedByList({ + instanceUuid: item.pk, + }); + }} + .delete=${(item: BlueprintInstance) => { + return new ManagedApi(DEFAULT_CONFIG).managedBlueprintsDestroy({ + instanceUuid: item.pk, + }); + }} + > + + `; + } + + row(item: BlueprintInstance): TemplateResult[] { + return [ + html`${item.name}`, + html`${BlueprintStatus(item)}`, + html`${item.lastApplied.toLocaleString()}`, + html` + ${item.enabled ? t`Yes` : t`No`} + `, + html` { + return new ManagedApi(DEFAULT_CONFIG) + .managedBlueprintsApplyCreate({ + instanceUuid: item.pk, + }) + .then(() => { + this.dispatchEvent( + new CustomEvent(EVENT_REFRESH, { + bubbles: true, + composed: true, + }), + ); + }); + }} + > + + + + ${t`Update`} + ${t`Update Blueprint`} + + + `, + ]; + } + + renderObjectCreate(): TemplateResult { + return html` + + ${t`Create`} + ${t`Create Blueprint Instance`} + + + + `; + } +} diff --git a/web/src/pages/system-tasks/SystemTaskListPage.ts b/web/src/pages/system-tasks/SystemTaskListPage.ts index 1a3dae35c..bc4a6e2f5 100644 --- a/web/src/pages/system-tasks/SystemTaskListPage.ts +++ b/web/src/pages/system-tasks/SystemTaskListPage.ts @@ -124,7 +124,7 @@ export class SystemTaskListPage extends TablePage { }); }} > - + `, ]; } diff --git a/web/src/routesAdmin.ts b/web/src/routesAdmin.ts index d7565f86a..c33944314 100644 --- a/web/src/routesAdmin.ts +++ b/web/src/routesAdmin.ts @@ -129,4 +129,8 @@ export const ROUTES: Route[] = [ await import("@goauthentik/web/pages/crypto/CertificateKeyPairListPage"); return html``; }), + new Route(new RegExp("^/blueprints/instances$"), async () => { + await import("@goauthentik/web/pages/blueprints/BlueprintListPage"); + return html``; + }), ];