blueprints: webui (#3356)

This commit is contained in:
Jens L 2022-08-03 00:05:49 +02:00 committed by GitHub
parent 20aeed139d
commit d1004e3798
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 918 additions and 192 deletions

View File

@ -172,3 +172,12 @@ ci-pending-migrations: ci--meta-debug
install: web-install website-install install: web-install website-install
poetry 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

View File

@ -1,11 +1,11 @@
"""test admin api""" """test admin api"""
from json import loads from json import loads
from django.apps import apps
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from authentik import __version__ from authentik import __version__
from authentik.blueprints.tests import reconcile_app
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.core.tasks import clean_expired_models from authentik.core.tasks import clean_expired_models
from authentik.events.monitored_tasks import TaskResultStatus from authentik.events.monitored_tasks import TaskResultStatus
@ -93,8 +93,8 @@ class TestAdminAPI(TestCase):
response = self.client.get(reverse("authentik_api:apps-list")) response = self.client.get(reverse("authentik_api:apps-list"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@reconcile_app("authentik_outposts")
def test_system(self): def test_system(self):
"""Test system API""" """Test system API"""
apps.get_app_config("authentik_outposts").reconcile_embedded_outpost()
response = self.client.get(reverse("authentik_api:admin_system")) response = self.client.get(reverse("authentik_api:admin_system"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)

View File

@ -1,13 +1,13 @@
"""Test API Authentication""" """Test API Authentication"""
from base64 import b64encode from base64 import b64encode
from django.apps import apps
from django.conf import settings from django.conf import settings
from django.test import TestCase from django.test import TestCase
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
from rest_framework.exceptions import AuthenticationFailed from rest_framework.exceptions import AuthenticationFailed
from authentik.api.authentication import bearer_auth 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.models import USER_ATTRIBUTE_SA, Token, TokenIntents
from authentik.core.tests.utils import create_test_flow from authentik.core.tests.utils import create_test_flow
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
@ -42,9 +42,11 @@ class TestAPIAuth(TestCase):
def test_managed_outpost(self): def test_managed_outpost(self):
"""Test managed outpost""" """Test managed outpost"""
with self.assertRaises(AuthenticationFailed): 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()) user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
self.assertEqual(user.attributes[USER_ATTRIBUTE_SA], True) self.assertEqual(user.attributes[USER_ATTRIBUTE_SA], True)

View File

@ -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

View File

@ -1,17 +1,20 @@
"""Serializer mixin for managed models""" """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.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.permissions import IsAdminUser
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ModelSerializer from rest_framework.serializers import ListSerializer, ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.api.decorators import permission_required
from authentik.blueprints.models import BlueprintInstance 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: class ManagedSerializer:
@ -20,6 +23,13 @@ class ManagedSerializer:
managed = CharField(read_only=True, allow_null=True) managed = CharField(read_only=True, allow_null=True)
class MetadataSerializer(PassiveSerializer):
"""Serializer for blueprint metadata"""
name = CharField()
labels = JSONField()
class BlueprintInstanceSerializer(ModelSerializer): class BlueprintInstanceSerializer(ModelSerializer):
"""Info about a single blueprint instance file""" """Info about a single blueprint instance file"""
@ -36,15 +46,18 @@ class BlueprintInstanceSerializer(ModelSerializer):
"status", "status",
"enabled", "enabled",
"managed_models", "managed_models",
"metadata",
] ]
extra_kwargs = { extra_kwargs = {
"status": {"read_only": True},
"last_applied": {"read_only": True}, "last_applied": {"read_only": True},
"last_applied_hash": {"read_only": True}, "last_applied_hash": {"read_only": True},
"managed_models": {"read_only": True}, "managed_models": {"read_only": True},
"metadata": {"read_only": True},
} }
class BlueprintInstanceViewSet(ModelViewSet): class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
"""Blueprint instances""" """Blueprint instances"""
permission_classes = [IsAdminUser] permission_classes = [IsAdminUser]
@ -53,12 +66,37 @@ class BlueprintInstanceViewSet(ModelViewSet):
search_fields = ["name", "path"] search_fields = ["name", "path"]
filterset_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=[]) @action(detail=False, pagination_class=None, filter_backends=[])
def available(self, request: Request) -> Response: def available(self, request: Request) -> Response:
"""Get blueprints""" """Get blueprints"""
files = [] files: list[BlueprintFile] = blueprints_find.delay().get()
for folder in CONFIG.y("blueprint_locations"): return Response([asdict(file) for file in files])
for file in glob(f"{folder}/**", recursive=True):
files.append(file) @permission_required("authentik_blueprints.view_blueprintinstance")
return Response(files) @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)

View File

@ -1,10 +1,13 @@
"""Apply blueprint from commandline""" """Apply blueprint from commandline"""
from django.core.management.base import BaseCommand, no_translations from django.core.management.base import BaseCommand, no_translations
from structlog.stdlib import get_logger
from authentik.blueprints.v1.importer import Importer from authentik.blueprints.v1.importer import Importer
LOGGER = get_logger()
class Command(BaseCommand): # pragma: no cover
class Command(BaseCommand):
"""Apply blueprint from commandline""" """Apply blueprint from commandline"""
@no_translations @no_translations
@ -15,7 +18,9 @@ class Command(BaseCommand): # pragma: no cover
importer = Importer(blueprint_file.read()) importer = Importer(blueprint_file.read())
valid, logs = importer.validate() valid, logs = importer.validate()
if not valid: if not valid:
raise ValueError(f"blueprint invalid: {logs}") for log in logs:
LOGGER.debug(**log)
raise ValueError("blueprint invalid")
importer.apply() importer.apply()
def add_arguments(self, parser): def add_arguments(self, parser):

View File

@ -4,7 +4,7 @@ from inspect import ismethod
from django.apps import AppConfig from django.apps import AppConfig
from django.db import DatabaseError, InternalError, ProgrammingError from django.db import DatabaseError, InternalError, ProgrammingError
from structlog.stdlib import get_logger from structlog.stdlib import BoundLogger, get_logger
LOGGER = get_logger() LOGGER = get_logger()
@ -12,6 +12,12 @@ LOGGER = get_logger()
class ManagedAppConfig(AppConfig): class ManagedAppConfig(AppConfig):
"""Basic reconciliation logic for apps""" """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: def ready(self) -> None:
self.reconcile() self.reconcile()
return super().ready() return super().ready()
@ -31,7 +37,8 @@ class ManagedAppConfig(AppConfig):
continue continue
name = meth_name.replace(prefix, "") name = meth_name.replace(prefix, "")
try: try:
self._logger.debug("Starting reconciler", name=name)
meth() meth()
LOGGER.debug("Successfully reconciled", name=name) self._logger.debug("Successfully reconciled", name=name)
except (DatabaseError, ProgrammingError, InternalError) as exc: 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)

View File

@ -4,7 +4,9 @@ from glob import glob
from pathlib import Path from pathlib import Path
import django.contrib.postgres.fields import django.contrib.postgres.fields
from dacite import from_dict
from django.apps.registry import Apps from django.apps.registry import Apps
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from yaml import load from yaml import load
@ -15,24 +17,33 @@ from authentik.lib.config import CONFIG
def check_blueprint_v1_file(BlueprintInstance: type["BlueprintInstance"], path: Path): def check_blueprint_v1_file(BlueprintInstance: type["BlueprintInstance"], path: Path):
"""Check if blueprint should be imported""" """Check if blueprint should be imported"""
from authentik.blueprints.models import BlueprintInstanceStatus 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: with open(path, "r", encoding="utf-8") as blueprint_file:
raw_blueprint = load(blueprint_file.read(), BlueprintLoader) raw_blueprint = load(blueprint_file.read(), BlueprintLoader)
metadata = raw_blueprint.get("metadata", None)
version = raw_blueprint.get("version", 1) version = raw_blueprint.get("version", 1)
if version != 1: if version != 1:
return return
blueprint_file.seek(0) blueprint_file.seek(0)
instance: BlueprintInstance = BlueprintInstance.objects.filter(path=path).first() 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: if not instance:
instance = BlueprintInstance( instance = BlueprintInstance(
name=path.name, name=meta.name if meta else str(rel_path),
path=str(path), path=str(path),
context={}, context={},
status=BlueprintInstanceStatus.UNKNOWN, status=BlueprintInstanceStatus.UNKNOWN,
enabled=True, enabled=True,
managed_models=[], managed_models=[],
last_applied_hash="", last_applied_hash="",
metadata=metadata,
) )
instance.save() instance.save()
@ -42,9 +53,8 @@ def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
Flow = apps.get_model("authentik_flows", "Flow") Flow = apps.get_model("authentik_flows", "Flow")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
for folder in CONFIG.y("blueprint_locations"): for file in glob(f"{CONFIG.y('blueprints_dir')}/**/*.yaml", recursive=True):
for file in glob(f"{folder}/**/*.yaml", recursive=True): check_blueprint_v1_file(BlueprintInstance, Path(file))
check_blueprint_v1_file(BlueprintInstance, Path(file))
for blueprint in BlueprintInstance.objects.using(db_alias).all(): for blueprint in BlueprintInstance.objects.using(db_alias).all():
# If we already have flows (and we should always run before flow migrations) # If we already have flows (and we should always run before flow migrations)
@ -86,8 +96,9 @@ class Migration(migrations.Migration):
), ),
), ),
("name", models.TextField()), ("name", models.TextField()),
("metadata", models.JSONField(default=dict)),
("path", models.TextField()), ("path", models.TextField()),
("context", models.JSONField()), ("context", models.JSONField(default=dict)),
("last_applied", models.DateTimeField(auto_now=True)), ("last_applied", models.DateTimeField(auto_now=True)),
("last_applied_hash", models.TextField()), ("last_applied_hash", models.TextField()),
( (
@ -106,7 +117,7 @@ class Migration(migrations.Migration):
( (
"managed_models", "managed_models",
django.contrib.postgres.fields.ArrayField( django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), size=None base_field=models.TextField(), default=list, size=None
), ),
), ),
], ],

View File

@ -49,13 +49,14 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
instance_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) instance_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
name = models.TextField() name = models.TextField()
metadata = models.JSONField(default=dict)
path = models.TextField() path = models.TextField()
context = models.JSONField() context = models.JSONField(default=dict)
last_applied = models.DateTimeField(auto_now=True) last_applied = models.DateTimeField(auto_now=True)
last_applied_hash = models.TextField() last_applied_hash = models.TextField()
status = models.TextField(choices=BlueprintInstanceStatus.choices) status = models.TextField(choices=BlueprintInstanceStatus.choices)
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
managed_models = ArrayField(models.TextField()) managed_models = ArrayField(models.TextField(), default=list)
@property @property
def serializer(self) -> Serializer: def serializer(self) -> Serializer:

View File

@ -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

View File

@ -6,12 +6,19 @@ from typing import Callable
from django.test import TransactionTestCase from django.test import TransactionTestCase
from django.utils.text import slugify from django.utils.text import slugify
from authentik.blueprints.tests import apply_blueprint
from authentik.blueprints.v1.importer import Importer from authentik.blueprints.v1.importer import Importer
from authentik.tenants.models import Tenant
class TestBundled(TransactionTestCase): class TestBundled(TransactionTestCase):
"""Empty class, test methods are added dynamically""" """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: def blueprint_tester(file_name: str) -> Callable:
"""This is used instead of subTest for better visibility""" """This is used instead of subTest for better visibility"""

View File

@ -1,4 +1,4 @@
"""Test flow Transport""" """Test blueprints v1"""
from django.test import TransactionTestCase from django.test import TransactionTestCase
from authentik.blueprints.v1.exporter import Exporter 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.prompt.models import FieldTypes, Prompt, PromptStage
from authentik.stages.user_login.models import UserLoginStage from authentik.stages.user_login.models import UserLoginStage
STATIC_PROMPT_EXPORT = """{ STATIC_PROMPT_EXPORT = """version: 1
"version": 1, entries:
"entries": [ - identifiers:
{ pk: cb954fd4-65a5-4ad9-b1ee-180ee9559cf4
"identifiers": { model: authentik_stages_prompt.prompt
"pk": "cb954fd4-65a5-4ad9-b1ee-180ee9559cf4" attrs:
}, field_key: username
"model": "authentik_stages_prompt.prompt", label: Username
"attrs": { type: username
"field_key": "username", required: true
"label": "Username", placeholder: Username
"type": "username", order: 0
"required": true, """
"placeholder": "Username",
"order": 0
}
}
]
}"""
class TestFlowTransport(TransactionTestCase): class TestBlueprintsV1(TransactionTestCase):
"""Test flow Transport""" """Test Blueprints"""
def test_bundle_invalid_format(self): def test_blueprint_invalid_format(self):
"""Test bundle with invalid format""" """Test blueprint with invalid format"""
importer = Importer('{"version": 3}') importer = Importer('{"version": 3}')
self.assertFalse(importer.validate()[0]) self.assertFalse(importer.validate()[0])
importer = Importer( importer = Importer(

View File

@ -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,
)

View File

@ -13,6 +13,7 @@ from yaml import SafeDumper, SafeLoader, ScalarNode, SequenceNode
from authentik.lib.models import SerializerModel from authentik.lib.models import SerializerModel
from authentik.lib.sentry import SentryIgnoredException from authentik.lib.sentry import SentryIgnoredException
from authentik.policies.models import PolicyBindingModel
def get_attrs(obj: SerializerModel) -> dict[str, Any]: def get_attrs(obj: SerializerModel) -> dict[str, Any]:
@ -83,6 +84,14 @@ class BlueprintEntry:
return self.tag_resolver(self.identifiers, blueprint) return self.tag_resolver(self.identifiers, blueprint)
@dataclass
class BlueprintMetadata:
"""Optional blueprint metadata"""
name: str
labels: dict[str, str] = field(default_factory=dict)
@dataclass @dataclass
class Blueprint: class Blueprint:
"""Dataclass used for a full export""" """Dataclass used for a full export"""
@ -90,6 +99,8 @@ class Blueprint:
version: int = field(default=1) version: int = field(default=1)
entries: list[BlueprintEntry] = field(default_factory=list) entries: list[BlueprintEntry] = field(default_factory=list)
metadata: Optional[BlueprintMetadata] = field(default=None)
class YAMLTag: class YAMLTag:
"""Base class for all YAML Tags""" """Base class for all YAML Tags"""
@ -112,6 +123,13 @@ class KeyOf(YAMLTag):
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
for _entry in blueprint.entries: for _entry in blueprint.entries:
if _entry.id == self.id_from and _entry._instance: 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 return _entry._instance.pk
raise ValueError( raise ValueError(
f"KeyOf: failed to find entry with `id` of `{self.id_from}` and a model instance" f"KeyOf: failed to find entry with `id` of `{self.id_from}` and a model instance"

View File

@ -74,6 +74,11 @@ class Importer:
except DaciteError as exc: except DaciteError as exc:
raise EntryInvalidError from 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]: 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""" """Replace any value if it is a known primary key of an other object"""
@ -190,7 +195,7 @@ class Importer:
try: try:
serializer = self._validate_single(entry) serializer = self._validate_single(entry)
except EntryInvalidError as exc: 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 return False
model = serializer.save() model = serializer.save()
@ -215,5 +220,7 @@ class Importer:
successful = self._apply_models() successful = self._apply_models()
if not successful: if not successful:
self.logger.debug("blueprint validation failed") self.logger.debug("blueprint validation failed")
for log in logs:
self.logger.debug(**log)
self.__import = orig_import self.__import = orig_import
return successful, logs return successful, logs

View File

@ -0,0 +1,4 @@
"""Blueprint labels"""
LABEL_AUTHENTIK_SYSTEM = "blueprints.goauthentik.io/system"
LABEL_AUTHENTIK_EXAMPLE = "blueprints.goauthentik.io/example"

View File

@ -1,14 +1,21 @@
"""v1 blueprints tasks""" """v1 blueprints tasks"""
from dataclasses import asdict, dataclass, field
from glob import glob from glob import glob
from hashlib import sha512 from hashlib import sha512
from pathlib import Path from pathlib import Path
from typing import Optional
from dacite import from_dict
from django.db import DatabaseError, InternalError, ProgrammingError 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 import load
from yaml.error import YAMLError
from authentik.blueprints.models import BlueprintInstance, BlueprintInstanceStatus 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.importer import Importer
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_EXAMPLE
from authentik.events.monitored_tasks import ( from authentik.events.monitored_tasks import (
MonitoredTask, MonitoredTask,
TaskResult, TaskResult,
@ -19,41 +26,85 @@ from authentik.lib.config import CONFIG
from authentik.root.celery import CELERY_APP 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( @CELERY_APP.task(
throws=(DatabaseError, ProgrammingError, InternalError), 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 @prefill_task
def blueprints_discover(): def blueprints_discover(self: MonitoredTask):
"""Find blueprints and check if they need to be created in the database""" """Find blueprints and check if they need to be created in the database"""
for folder in CONFIG.y("blueprint_locations"): count = 0
for file in glob(f"{folder}/**/*.yaml", recursive=True): for blueprint in blueprints_find():
check_blueprint_v1_file(Path(file)) 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""" """Check if blueprint should be imported"""
with open(path, "r", encoding="utf-8") as blueprint_file: rel_path = Path(blueprint.path).relative_to(Path(CONFIG.y("blueprints_dir")))
raw_blueprint = load(blueprint_file.read(), BlueprintLoader) instance: BlueprintInstance = BlueprintInstance.objects.filter(path=blueprint.path).first()
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()
if not instance: if not instance:
instance = BlueprintInstance( instance = BlueprintInstance(
name=path.name, name=blueprint.meta.name if blueprint.meta else str(rel_path),
path=str(path), path=blueprint.path,
context={}, context={},
status=BlueprintInstanceStatus.UNKNOWN, status=BlueprintInstanceStatus.UNKNOWN,
enabled=True, enabled=True,
managed_models=[], managed_models=[],
metadata={},
) )
instance.save() instance.save()
if instance.last_applied_hash != file_hash: if instance.last_applied_hash != blueprint.hash:
apply_blueprint.delay(instance.pk.hex) instance.metadata = asdict(blueprint.meta) if blueprint.meta else {}
instance.last_applied_hash = file_hash
instance.save() instance.save()
apply_blueprint.delay(instance.pk.hex)
@CELERY_APP.task( @CELERY_APP.task(
@ -62,25 +113,33 @@ def check_blueprint_v1_file(path: Path):
) )
def apply_blueprint(self: MonitoredTask, instance_pk: str): def apply_blueprint(self: MonitoredTask, instance_pk: str):
"""Apply single blueprint""" """Apply single blueprint"""
self.set_uid(instance_pk)
self.save_on_success = False self.save_on_success = False
try: try:
instance: BlueprintInstance = BlueprintInstance.objects.filter(pk=instance_pk).first() instance: BlueprintInstance = BlueprintInstance.objects.filter(pk=instance_pk).first()
if not instance or not instance.enabled: if not instance or not instance.enabled:
return return
file_hash = sha512(Path(instance.path).read_bytes()).hexdigest()
with open(instance.path, "r", encoding="utf-8") as blueprint_file: with open(instance.path, "r", encoding="utf-8") as blueprint_file:
importer = Importer(blueprint_file.read()) importer = Importer(blueprint_file.read())
valid, logs = importer.validate() valid, logs = importer.validate()
if not valid: if not valid:
instance.status = BlueprintInstanceStatus.ERROR instance.status = BlueprintInstanceStatus.ERROR
instance.save() instance.save()
self.set_status(TaskResult(TaskResultStatus.ERROR, [x["event"] for x in logs])) self.set_status(TaskResult(TaskResultStatus.ERROR, [x["event"] for x in logs]))
return return
applied = importer.apply() applied = importer.apply()
if not applied: if not applied:
instance.status = BlueprintInstanceStatus.ERROR instance.status = BlueprintInstanceStatus.ERROR
instance.save() instance.save()
self.set_status(TaskResult(TaskResultStatus.ERROR, "Failed to apply")) self.set_status(TaskResult(TaskResultStatus.ERROR, "Failed to apply"))
except (DatabaseError, ProgrammingError, InternalError) as exc: 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.status = BlueprintInstanceStatus.ERROR
instance.save() instance.save()
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))

View File

@ -4,7 +4,7 @@ from django.core.management.base import BaseCommand
from authentik.root.celery import _get_startup_tasks 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""" """Run bootstrap tasks to ensure certain objects are created"""
def handle(self, **options): def handle(self, **options):

View File

@ -5,7 +5,7 @@ from django.core.management.base import BaseCommand, no_translations
from guardian.management import create_anonymous_user from guardian.management import create_anonymous_user
class Command(BaseCommand): # pragma: no cover class Command(BaseCommand):
"""Repair missing permissions""" """Repair missing permissions"""
@no_translations @no_translations

View File

@ -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""" """Start the Django shell with all authentik models already imported"""
django_models = {} django_models = {}

View File

@ -3,6 +3,7 @@ from datetime import datetime
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from authentik.blueprints.manager import ManagedAppConfig from authentik.blueprints.manager import ManagedAppConfig
from authentik.lib.generators import generate_id
if TYPE_CHECKING: if TYPE_CHECKING:
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
@ -53,3 +54,19 @@ class AuthentikCryptoConfig(ManagedAppConfig):
now = datetime.now() now = datetime.now()
if now < cert.certificate.not_valid_before or now > cert.certificate.not_valid_after: if now < cert.certificate.not_valid_before or now > cert.certificate.not_valid_after:
self._create_update_cert(cert) 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,
)

View File

@ -5,24 +5,10 @@ from django.db import migrations
from authentik.lib.generators import generate_id 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): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("authentik_crypto", "0001_initial"), ("authentik_crypto", "0001_initial"),
] ]
operations = [migrations.RunPython(create_self_signed)] operations = []

View File

@ -48,7 +48,7 @@ class FlowPlanProcess(PROCESS_CLASS): # pragma: no cover
self.return_dict[self.index] = diffs self.return_dict[self.index] = diffs
class Command(BaseCommand): # pragma: no cover class Command(BaseCommand):
"""Benchmark authentik""" """Benchmark authentik"""
def add_arguments(self, parser): def add_arguments(self, parser):

View File

@ -79,5 +79,4 @@ cert_discovery_dir: /certs
default_token_length: 128 default_token_length: 128
impersonation: true impersonation: true
blueprint_locations: blueprints_dir: /blueprints
- /blueprints

View File

@ -1,8 +1,8 @@
"""Docker controller tests""" """Docker controller tests"""
from django.apps import apps
from django.test import TestCase from django.test import TestCase
from docker.models.containers import Container from docker.models.containers import Container
from authentik.blueprints.tests import reconcile_app
from authentik.outposts.apps import MANAGED_OUTPOST from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.controllers.base import ControllerException from authentik.outposts.controllers.base import ControllerException
from authentik.outposts.controllers.docker import DockerController from authentik.outposts.controllers.docker import DockerController
@ -13,13 +13,13 @@ from authentik.providers.proxy.controllers.docker import ProxyDockerController
class DockerControllerTests(TestCase): class DockerControllerTests(TestCase):
"""Docker controller tests""" """Docker controller tests"""
@reconcile_app("authentik_outposts")
def setUp(self) -> None: def setUp(self) -> None:
self.outpost = Outpost.objects.create( self.outpost = Outpost.objects.create(
name="test", name="test",
type=OutpostType.PROXY, type=OutpostType.PROXY,
) )
self.integration = DockerServiceConnection(name="test") self.integration = DockerServiceConnection(name="test")
apps.get_app_config("authentik_outposts").reconcile()
def test_init_managed(self): def test_init_managed(self):
"""Docker controller shouldn't do anything for managed outpost""" """Docker controller shouldn't do anything for managed outpost"""

View File

@ -5,7 +5,7 @@ from django.test import RequestFactory
from django.urls import reverse from django.urls import reverse
from jwt import decode 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.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.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.lib.generators import generate_id, generate_key from authentik.lib.generators import generate_id, generate_key

View File

@ -6,7 +6,7 @@ from django.test import RequestFactory
from django.urls import reverse from django.urls import reverse
from jwt import decode 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.models import Application, Group
from authentik.core.tests.utils import create_test_cert, create_test_flow from authentik.core.tests.utils import create_test_cert, create_test_flow
from authentik.lib.generators import generate_id, generate_key from authentik.lib.generators import generate_id, generate_key

View File

@ -4,7 +4,7 @@ from dataclasses import asdict
from django.urls import reverse 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.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction

View File

@ -4,7 +4,7 @@ from base64 import b64encode
from django.http.request import QueryDict from django.http.request import QueryDict
from django.test import RequestFactory, TestCase 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.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction

View File

@ -4,7 +4,7 @@ from base64 import b64encode
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from lxml import etree # nosec 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.core.tests.utils import create_test_cert, create_test_flow
from authentik.lib.tests.utils import get_request from authentik.lib.tests.utils import get_request
from authentik.lib.xml import lxml_from_string from authentik.lib.xml import lxml_from_string

View File

@ -4,7 +4,7 @@ from unittest.mock import Mock, PropertyMock, patch
from django.db.models import Q from django.db.models import Q
from django.test import TestCase 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.core.models import User
from authentik.lib.generators import generate_key from authentik.lib.generators import generate_key
from authentik.sources.ldap.auth import LDAPBackend from authentik.sources.ldap.auth import LDAPBackend

View File

@ -4,7 +4,7 @@ from unittest.mock import PropertyMock, patch
from django.db.models import Q from django.db.models import Q
from django.test import TestCase 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.models import Group, User
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction

View File

@ -8,7 +8,7 @@ from authentik.stages.email.tasks import send_mail
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
class Command(BaseCommand): # pragma: no cover class Command(BaseCommand):
"""Send a test-email with global settings""" """Send a test-email with global settings"""
@no_translations @no_translations

View File

@ -1,3 +1,5 @@
metadata:
name: Default - Password change flow
entries: entries:
- attrs: - attrs:
compatibility_mode: false compatibility_mode: false

View File

@ -1,3 +1,5 @@
metadata:
name: Default - Authentication flow
entries: entries:
- attrs: - attrs:
cache_count: 1 cache_count: 1

View File

@ -1,3 +1,5 @@
metadata:
name: Default - Invalidation flow
entries: entries:
- attrs: - attrs:
compatibility_mode: false compatibility_mode: false

View File

@ -1,3 +1,5 @@
metadata:
name: Default - Static MFA setup flow
entries: entries:
- attrs: - attrs:
compatibility_mode: false compatibility_mode: false

View File

@ -1,3 +1,5 @@
metadata:
name: Default - TOTP MFA setup flow
entries: entries:
- attrs: - attrs:
compatibility_mode: false compatibility_mode: false

View File

@ -1,3 +1,5 @@
metadata:
name: Default - WebAuthn MFA setup flow
entries: entries:
- attrs: - attrs:
compatibility_mode: false compatibility_mode: false

View File

@ -1,3 +1,5 @@
metadata:
name: Default - Provider authorization flow (explicit consent)
entries: entries:
- attrs: - attrs:
compatibility_mode: false compatibility_mode: false

View File

@ -1,3 +1,5 @@
metadata:
name: Default - Provider authorization flow (implicit consent)
entries: entries:
- attrs: - attrs:
compatibility_mode: false compatibility_mode: false

View File

@ -1,3 +1,5 @@
metadata:
name: Default - Source authentication flow
entries: entries:
- attrs: - attrs:
compatibility_mode: false compatibility_mode: false

View File

@ -1,3 +1,5 @@
metadata:
name: Default - Source enrollment flow
entries: entries:
- attrs: - attrs:
compatibility_mode: false compatibility_mode: false

View File

@ -1,3 +1,5 @@
metadata:
name: Default - Source pre-authentication flow
entries: entries:
- attrs: - attrs:
compatibility_mode: false compatibility_mode: false

View File

@ -1,3 +1,5 @@
metadata:
name: Default - User settings flow
entries: entries:
- attrs: - attrs:
compatibility_mode: false compatibility_mode: false

View File

@ -1,3 +1,5 @@
metadata:
name: Default - Tenant
version: 1 version: 1
entries: entries:
- attrs: - attrs:

View File

@ -1,4 +1,8 @@
version: 1 version: 1
metadata:
labels:
blueprints.goauthentik.io/example: "true"
name: Example - Enrollment (2 Stage)
entries: entries:
- identifiers: - identifiers:
slug: default-enrollment-flow slug: default-enrollment-flow

View File

@ -1,4 +1,8 @@
version: 1 version: 1
metadata:
labels:
blueprints.goauthentik.io/example: "true"
name: Example - Enrollment with email verification
entries: entries:
- identifiers: - identifiers:
slug: default-enrollment-flow slug: default-enrollment-flow

View File

@ -1,4 +1,8 @@
version: 1 version: 1
metadata:
labels:
blueprints.goauthentik.io/example: "true"
name: Example - Two-factor Login
entries: entries:
- identifiers: - identifiers:
slug: default-authentication-flow slug: default-authentication-flow

View File

@ -1,4 +1,8 @@
version: 1 version: 1
metadata:
labels:
blueprints.goauthentik.io/example: "true"
name: Example - Login with conditional Captcha
entries: entries:
- identifiers: - identifiers:
slug: default-authentication-flow slug: default-authentication-flow

View File

@ -1,4 +1,8 @@
version: 1 version: 1
metadata:
labels:
blueprints.goauthentik.io/example: "true"
name: Example - Recovery with email verification
entries: entries:
- identifiers: - identifiers:
slug: default-recovery-flow slug: default-recovery-flow

View File

@ -1,4 +1,8 @@
version: 1 version: 1
metadata:
labels:
blueprints.goauthentik.io/example: "true"
name: Example - User deletion
entries: entries:
- identifiers: - identifiers:
slug: default-unenrollment-flow slug: default-unenrollment-flow

View File

@ -1,4 +1,8 @@
version: 1 version: 1
metadata:
labels:
blueprints.goauthentik.io/system: "true"
name: System - OAuth2 Provider - Scopes
entries: entries:
- identifiers: - identifiers:
managed: goauthentik.io/providers/oauth2/scope-openid managed: goauthentik.io/providers/oauth2/scope-openid

View File

@ -1,4 +1,8 @@
version: 1 version: 1
metadata:
labels:
blueprints.goauthentik.io/system: "true"
name: System - Proxy Provider - Scopes
entries: entries:
- identifiers: - identifiers:
managed: goauthentik.io/providers/proxy/scope-proxy managed: goauthentik.io/providers/proxy/scope-proxy

View File

@ -1,4 +1,8 @@
version: 1 version: 1
metadata:
labels:
blueprints.goauthentik.io/system: "true"
name: System - SAML Provider - Mappings
entries: entries:
- identifiers: - identifiers:
managed: goauthentik.io/providers/saml/upn managed: goauthentik.io/providers/saml/upn

View File

@ -1,4 +1,8 @@
version: 1 version: 1
metadata:
labels:
blueprints.goauthentik.io/system: "true"
name: System - LDAP Source - Mappings
entries: entries:
- identifiers: - identifiers:
managed: goauthentik.io/sources/ldap/default-name managed: goauthentik.io/sources/ldap/default-name

View File

@ -29,7 +29,14 @@ force_to_top = "*"
[tool.coverage.run] [tool.coverage.run]
source = ["authentik"] source = ["authentik"]
relative_files = true 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] [tool.coverage.report]
sort = "Cover" sort = "Cover"

View File

@ -6218,6 +6218,62 @@ paths:
$ref: '#/components/schemas/ValidationError' $ref: '#/components/schemas/ValidationError'
'403': '403':
$ref: '#/components/schemas/GenericError' $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/: /managed/blueprints/available/:
get: get:
operationId: managed_blueprints_available_list operationId: managed_blueprints_available_list
@ -6233,7 +6289,7 @@ paths:
schema: schema:
type: array type: array
items: items:
type: string $ref: '#/components/schemas/BlueprintFile'
description: '' description: ''
'400': '400':
$ref: '#/components/schemas/ValidationError' $ref: '#/components/schemas/ValidationError'
@ -20862,6 +20918,25 @@ components:
- POST - POST
- POST_AUTO - POST_AUTO
type: string 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: BlueprintInstance:
type: object type: object
description: Info about a single blueprint instance file description: Info about a single blueprint instance file
@ -20886,7 +20961,9 @@ components:
type: string type: string
readOnly: true readOnly: true
status: status:
$ref: '#/components/schemas/BlueprintInstanceStatusEnum' allOf:
- $ref: '#/components/schemas/BlueprintInstanceStatusEnum'
readOnly: true
enabled: enabled:
type: boolean type: boolean
managed_models: managed_models:
@ -20894,11 +20971,15 @@ components:
items: items:
type: string type: string
readOnly: true readOnly: true
metadata:
type: object
additionalProperties: {}
readOnly: true
required: required:
- context
- last_applied - last_applied
- last_applied_hash - last_applied_hash
- managed_models - managed_models
- metadata
- name - name
- path - path
- pk - pk
@ -20916,15 +20997,11 @@ components:
context: context:
type: object type: object
additionalProperties: {} additionalProperties: {}
status:
$ref: '#/components/schemas/BlueprintInstanceStatusEnum'
enabled: enabled:
type: boolean type: boolean
required: required:
- context
- name - name
- path - path
- status
BlueprintInstanceStatusEnum: BlueprintInstanceStatusEnum:
enum: enum:
- successful - successful
@ -23774,6 +23851,18 @@ components:
required: required:
- challenge - challenge
- name - name
Metadata:
type: object
description: Serializer for blueprint metadata
properties:
name:
type: string
labels:
type: object
additionalProperties: {}
required:
- labels
- name
NameIdPolicyEnum: NameIdPolicyEnum:
enum: enum:
- urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
@ -27808,8 +27897,6 @@ components:
context: context:
type: object type: object
additionalProperties: {} additionalProperties: {}
status:
$ref: '#/components/schemas/BlueprintInstanceStatusEnum'
enabled: enabled:
type: boolean type: boolean
PatchedCaptchaStageRequest: PatchedCaptchaStageRequest:

View File

@ -13,8 +13,8 @@ with open("local.env.yml", "w") as _config:
}, },
"outposts": { "outposts": {
"container_image_base": "ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s", "container_image_base": "ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s",
"blueprint_locations": ["./blueprints"],
}, },
"blueprints_dir": "./blueprints",
"web": { "web": {
"outpost_port_offset": 100, "outpost_port_offset": 100,
}, },

View File

@ -13,7 +13,7 @@ from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.wait import WebDriverWait 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.flows.models import Flow
from authentik.stages.authenticator_static.models import AuthenticatorStaticStage from authentik.stages.authenticator_static.models import AuthenticatorStaticStage
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage

View File

@ -9,7 +9,7 @@ from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.wait import WebDriverWait 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.models import User
from authentik.core.tests.utils import create_test_flow from authentik.core.tests.utils import create_test_flow
from authentik.flows.models import FlowDesignation, FlowStageBinding from authentik.flows.models import FlowDesignation, FlowStageBinding

View File

@ -2,7 +2,7 @@
from sys import platform from sys import platform
from unittest.case import skipUnless 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 from tests.e2e.utils import SeleniumTestCase, retry

View File

@ -5,7 +5,7 @@ from unittest.case import skipUnless
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys 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.core.models import User
from authentik.flows.models import Flow, FlowDesignation from authentik.flows.models import Flow, FlowDesignation
from authentik.lib.generators import generate_key from authentik.lib.generators import generate_key

View File

@ -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 import ALL, ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE, Connection, Server
from ldap3.core.exceptions import LDAPInvalidCredentialsResult 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.core.models import Application, User
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.outposts.apps import MANAGED_OUTPOST from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost, OutpostConfig, OutpostType from authentik.outposts.models import Outpost, OutpostConfig, OutpostType
from authentik.providers.ldap.models import APIAccessMode, LDAPProvider 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") @skipUnless(platform.startswith("linux"), "requires local docker")

View File

@ -8,14 +8,14 @@ from docker.types import Healthcheck
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec 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.models import Application
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.lib.generators import generate_id, generate_key from authentik.lib.generators import generate_id, generate_key
from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import PolicyBinding from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider 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") @skipUnless(platform.startswith("linux"), "requires local docker")

View File

@ -8,7 +8,7 @@ from docker.types import Healthcheck
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec 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.models import Application
from authentik.core.tests.utils import create_test_cert from authentik.core.tests.utils import create_test_cert
from authentik.flows.models import Flow from authentik.flows.models import Flow
@ -21,7 +21,7 @@ from authentik.providers.oauth2.constants import (
SCOPE_OPENID_PROFILE, SCOPE_OPENID_PROFILE,
) )
from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping 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") @skipUnless(platform.startswith("linux"), "requires local docker")

View File

@ -10,7 +10,7 @@ from docker.types import Healthcheck
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec 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.models import Application
from authentik.core.tests.utils import create_test_cert from authentik.core.tests.utils import create_test_cert
from authentik.flows.models import Flow from authentik.flows.models import Flow
@ -23,7 +23,7 @@ from authentik.providers.oauth2.constants import (
SCOPE_OPENID_PROFILE, SCOPE_OPENID_PROFILE,
) )
from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping 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") @skipUnless(platform.startswith("linux"), "requires local docker")

View File

@ -10,7 +10,7 @@ from docker.types import Healthcheck
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec 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.models import Application
from authentik.core.tests.utils import create_test_cert from authentik.core.tests.utils import create_test_cert
from authentik.flows.models import Flow from authentik.flows.models import Flow
@ -23,7 +23,7 @@ from authentik.providers.oauth2.constants import (
SCOPE_OPENID_PROFILE, SCOPE_OPENID_PROFILE,
) )
from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping 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") @skipUnless(platform.startswith("linux"), "requires local docker")

View File

@ -11,13 +11,13 @@ from docker.models.containers import Container
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from authentik import __version__ 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.core.models import Application
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.outposts.models import DockerServiceConnection, Outpost, OutpostConfig, OutpostType from authentik.outposts.models import DockerServiceConnection, Outpost, OutpostConfig, OutpostType
from authentik.outposts.tasks import outpost_local_connection from authentik.outposts.tasks import outpost_local_connection
from authentik.providers.proxy.models import ProxyProvider 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") @skipUnless(platform.startswith("linux"), "requires local docker")

View File

@ -10,7 +10,7 @@ from docker.types import Healthcheck
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec 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.models import Application
from authentik.core.tests.utils import create_test_cert from authentik.core.tests.utils import create_test_cert
from authentik.flows.models import Flow 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.policies.models import PolicyBinding
from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider
from authentik.sources.saml.processors.constants import SAML_BINDING_POST 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") @skipUnless(platform.startswith("linux"), "requires local docker")

View File

@ -13,7 +13,7 @@ from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.wait import WebDriverWait from selenium.webdriver.support.wait import WebDriverWait
from yaml import safe_dump 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.core.models import User
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.lib.generators import generate_id, generate_key from authentik.lib.generators import generate_id, generate_key

View File

@ -11,7 +11,7 @@ from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.wait import WebDriverWait 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.models import User
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow from authentik.flows.models import Flow

View File

@ -6,7 +6,6 @@ from os import environ, makedirs
from time import sleep, time from time import sleep, time
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
from django.apps import apps
from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from django.db import connection from django.db import connection
from django.db.migrations.loader import MigrationLoader 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 selenium.webdriver.support.ui import WebDriverWait
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.blueprints.manager import ManagedAppConfig
from authentik.core.api.users import UserSerializer from authentik.core.api.users import UserSerializer
from authentik.core.models import User from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
@ -192,24 +190,6 @@ def get_loader():
return MigrationLoader(connection) 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): def retry(max_retires=RETRIES, exceptions=None):
"""Retry test multiple times. Default to catching Selenium Timeout Exception""" """Retry test multiple times. Default to catching Selenium Timeout Exception"""

View File

@ -300,6 +300,9 @@ export class AdminInterface extends LitElement {
<ak-sidebar-item path="/crypto/certificates"> <ak-sidebar-item path="/crypto/certificates">
<span slot="label">${t`Certificates`}</span> <span slot="label">${t`Certificates`}</span>
</ak-sidebar-item> </ak-sidebar-item>
<ak-sidebar-item path="/blueprints/instances">
<span slot="label">${t`Blueprints`}</span>
</ak-sidebar-item>
</ak-sidebar-item> </ak-sidebar-item>
`; `;
} }

View File

@ -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<BlueprintInstance, string> {
loadInstance(pk: string): Promise<BlueprintInstance> {
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<BlueprintInstance> => {
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`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`Name`} ?required=${true} name="name">
<input
type="text"
value="${first(this.instance?.name)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="enabled">
<div class="pf-c-check">
<input
type="checkbox"
class="pf-c-check__input"
?checked=${first(this.instance?.enabled, false)}
/>
<label class="pf-c-check__label"> ${t`Enabled`} </label>
</div>
<p class="pf-c-form__helper-text">${t`Disabled blueprints are never applied.`}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Path`} name="path">
<select class="pf-c-form-control">
${until(
new ManagedApi(DEFAULT_CONFIG)
.managedBlueprintsAvailableList()
.then((files) => {
return files.map((file) => {
let name = file.path;
if (file.meta && file.meta.name) {
name = `${name} (${file.meta.name})`;
}
const selected = file.path === this.instance?.path;
return html`<option ?selected=${selected} value=${file.path}>
${name}
</option>`;
});
}),
html`<option>${t`Loading...`}</option>`,
)}
</select>
</ak-form-element-horizontal>
<ak-form-group>
<span slot="header">${t`Additional settings`}</span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${t`Context`} name="context">
<ak-codemirror
mode="yaml"
value="${YAML.stringify(first(this.instance?.context, {}))}"
>
</ak-codemirror>
<p class="pf-c-form__helper-text">
${t`Configure the blueprint context, used for templating.`}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
</form>`;
}
}

View File

@ -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<BlueprintInstance> {
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<AKResponse<BlueprintInstance>> {
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`<ak-forms-delete-bulk
objectLabel=${t`Blueprint(s)`}
.objects=${this.selectedElements}
.metadata=${(item: BlueprintInstance) => {
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,
});
}}
>
<button ?disabled=${disabled} slot="trigger" class="pf-c-button pf-m-danger">
${t`Delete`}
</button>
</ak-forms-delete-bulk>`;
}
row(item: BlueprintInstance): TemplateResult[] {
return [
html`${item.name}`,
html`${BlueprintStatus(item)}`,
html`${item.lastApplied.toLocaleString()}`,
html`<ak-label color=${item.enabled ? PFColor.Green : PFColor.Red}>
${item.enabled ? t`Yes` : t`No`}
</ak-label>`,
html`<ak-action-button
class="pf-m-plain"
.apiRequest=${() => {
return new ManagedApi(DEFAULT_CONFIG)
.managedBlueprintsApplyCreate({
instanceUuid: item.pk,
})
.then(() => {
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
});
}}
>
<i class="fas fa-play" aria-hidden="true"></i>
</ak-action-button>
<ak-forms-modal>
<span slot="submit"> ${t`Update`} </span>
<span slot="header"> ${t`Update Blueprint`} </span>
<ak-blueprint-form slot="form" .instancePk=${item.pk}> </ak-blueprint-form>
<button slot="trigger" class="pf-c-button pf-m-plain">
<i class="fas fa-edit"></i>
</button>
</ak-forms-modal>`,
];
}
renderObjectCreate(): TemplateResult {
return html`
<ak-forms-modal>
<span slot="submit"> ${t`Create`} </span>
<span slot="header"> ${t`Create Blueprint Instance`} </span>
<ak-blueprint-form slot="form"> </ak-blueprint-form>
<button slot="trigger" class="pf-c-button pf-m-primary">${t`Create`}</button>
</ak-forms-modal>
`;
}
}

View File

@ -124,7 +124,7 @@ export class SystemTaskListPage extends TablePage<Task> {
}); });
}} }}
> >
<i class="fas fa-sync-alt" aria-hidden="true"></i> <i class="fas fa-play" aria-hidden="true"></i>
</ak-action-button>`, </ak-action-button>`,
]; ];
} }

View File

@ -129,4 +129,8 @@ export const ROUTES: Route[] = [
await import("@goauthentik/web/pages/crypto/CertificateKeyPairListPage"); await import("@goauthentik/web/pages/crypto/CertificateKeyPairListPage");
return html`<ak-crypto-certificate-list></ak-crypto-certificate-list>`; return html`<ak-crypto-certificate-list></ak-crypto-certificate-list>`;
}), }),
new Route(new RegExp("^/blueprints/instances$"), async () => {
await import("@goauthentik/web/pages/blueprints/BlueprintListPage");
return html`<ak-blueprint-list></ak-blueprint-list>`;
}),
]; ];