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
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"""
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)

View File

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

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"""
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)

View File

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

View File

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

View File

@ -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,8 +53,7 @@ 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):
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():
@ -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
),
),
],

View File

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

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.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"""

View File

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

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.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"

View File

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

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"""
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),
)
@prefill_task
def blueprints_discover():
"""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))
def check_blueprint_v1_file(path: Path):
"""Check if blueprint should be imported"""
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:
return
blueprint_file.seek(0)
continue
file_hash = sha512(path.read_bytes()).hexdigest()
instance: BlueprintInstance = BlueprintInstance.objects.filter(path=path).first()
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(self: MonitoredTask):
"""Find blueprints and check if they need to be created in the database"""
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(blueprint: BlueprintFile):
"""Check if blueprint should be imported"""
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,11 +113,13 @@ 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()
@ -80,7 +133,13 @@ def apply_blueprint(self: MonitoredTask, instance_pk: str):
instance.status = BlueprintInstanceStatus.ERROR
instance.save()
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.save()
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
class Command(BaseCommand): # pragma: no cover
class Command(BaseCommand):
"""Run bootstrap tasks to ensure certain objects are created"""
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
class Command(BaseCommand): # pragma: no cover
class Command(BaseCommand):
"""Repair missing permissions"""
@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"""
django_models = {}

View File

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

View File

@ -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 = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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.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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -129,4 +129,8 @@ export const ROUTES: Route[] = [
await import("@goauthentik/web/pages/crypto/CertificateKeyPairListPage");
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>`;
}),
];