blueprints: webui (#3356)
This commit is contained in:
parent
20aeed139d
commit
d1004e3798
9
Makefile
9
Makefile
|
@ -172,3 +172,12 @@ ci-pending-migrations: ci--meta-debug
|
|||
|
||||
install: web-install website-install
|
||||
poetry install
|
||||
|
||||
dev-reset:
|
||||
dropdb -U postgres -h localhost authentik
|
||||
createdb -U postgres -h localhost authentik
|
||||
redis-cli -n 0 flushall
|
||||
redis-cli -n 1 flushall
|
||||
redis-cli -n 2 flushall
|
||||
redis-cli -n 3 flushall
|
||||
make migrate
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -4,7 +4,9 @@ from glob import glob
|
|||
from pathlib import Path
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from dacite import from_dict
|
||||
from django.apps.registry import Apps
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
from yaml import load
|
||||
|
@ -15,24 +17,33 @@ from authentik.lib.config import CONFIG
|
|||
def check_blueprint_v1_file(BlueprintInstance: type["BlueprintInstance"], path: Path):
|
||||
"""Check if blueprint should be imported"""
|
||||
from authentik.blueprints.models import BlueprintInstanceStatus
|
||||
from authentik.blueprints.v1.common import BlueprintLoader
|
||||
from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata
|
||||
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_EXAMPLE
|
||||
|
||||
with open(path, "r", encoding="utf-8") as blueprint_file:
|
||||
raw_blueprint = load(blueprint_file.read(), BlueprintLoader)
|
||||
metadata = raw_blueprint.get("metadata", None)
|
||||
version = raw_blueprint.get("version", 1)
|
||||
if version != 1:
|
||||
return
|
||||
blueprint_file.seek(0)
|
||||
instance: BlueprintInstance = BlueprintInstance.objects.filter(path=path).first()
|
||||
rel_path = path.relative_to(Path(CONFIG.y("blueprints_dir")))
|
||||
meta = None
|
||||
if metadata:
|
||||
meta = from_dict(BlueprintMetadata, metadata)
|
||||
if meta.labels.get(LABEL_AUTHENTIK_EXAMPLE, "").lower() == "true":
|
||||
return
|
||||
if not instance:
|
||||
instance = BlueprintInstance(
|
||||
name=path.name,
|
||||
name=meta.name if meta else str(rel_path),
|
||||
path=str(path),
|
||||
context={},
|
||||
status=BlueprintInstanceStatus.UNKNOWN,
|
||||
enabled=True,
|
||||
managed_models=[],
|
||||
last_applied_hash="",
|
||||
metadata=metadata,
|
||||
)
|
||||
instance.save()
|
||||
|
||||
|
@ -42,9 +53,8 @@ def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
|
|||
Flow = apps.get_model("authentik_flows", "Flow")
|
||||
|
||||
db_alias = schema_editor.connection.alias
|
||||
for folder in CONFIG.y("blueprint_locations"):
|
||||
for file in glob(f"{folder}/**/*.yaml", recursive=True):
|
||||
check_blueprint_v1_file(BlueprintInstance, Path(file))
|
||||
for file in glob(f"{CONFIG.y('blueprints_dir')}/**/*.yaml", recursive=True):
|
||||
check_blueprint_v1_file(BlueprintInstance, Path(file))
|
||||
|
||||
for blueprint in BlueprintInstance.objects.using(db_alias).all():
|
||||
# If we already have flows (and we should always run before flow migrations)
|
||||
|
@ -86,8 +96,9 @@ class Migration(migrations.Migration):
|
|||
),
|
||||
),
|
||||
("name", models.TextField()),
|
||||
("metadata", models.JSONField(default=dict)),
|
||||
("path", models.TextField()),
|
||||
("context", models.JSONField()),
|
||||
("context", models.JSONField(default=dict)),
|
||||
("last_applied", models.DateTimeField(auto_now=True)),
|
||||
("last_applied_hash", models.TextField()),
|
||||
(
|
||||
|
@ -106,7 +117,7 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
"managed_models",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(), size=None
|
||||
base_field=models.TextField(), default=list, size=None
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
|
@ -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"""
|
||||
|
|
|
@ -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(
|
140
authentik/blueprints/tests/test_v1_tasks.py
Normal file
140
authentik/blueprints/tests/test_v1_tasks.py
Normal 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,
|
||||
)
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
4
authentik/blueprints/v1/labels.py
Normal file
4
authentik/blueprints/v1/labels.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
"""Blueprint labels"""
|
||||
|
||||
LABEL_AUTHENTIK_SYSTEM = "blueprints.goauthentik.io/system"
|
||||
LABEL_AUTHENTIK_EXAMPLE = "blueprints.goauthentik.io/example"
|
|
@ -1,14 +1,21 @@
|
|||
"""v1 blueprints tasks"""
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from glob import glob
|
||||
from hashlib import sha512
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from dacite import from_dict
|
||||
from django.db import DatabaseError, InternalError, ProgrammingError
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from yaml import load
|
||||
from yaml.error import YAMLError
|
||||
|
||||
from authentik.blueprints.models import BlueprintInstance, BlueprintInstanceStatus
|
||||
from authentik.blueprints.v1.common import BlueprintLoader
|
||||
from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_EXAMPLE
|
||||
from authentik.events.monitored_tasks import (
|
||||
MonitoredTask,
|
||||
TaskResult,
|
||||
|
@ -19,41 +26,85 @@ from authentik.lib.config import CONFIG
|
|||
from authentik.root.celery import CELERY_APP
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlueprintFile:
|
||||
"""Basic info about a blueprint file"""
|
||||
|
||||
path: str
|
||||
version: int
|
||||
hash: str
|
||||
last_m: int
|
||||
meta: Optional[BlueprintMetadata] = field(default=None)
|
||||
|
||||
|
||||
@CELERY_APP.task(
|
||||
throws=(DatabaseError, ProgrammingError, InternalError),
|
||||
)
|
||||
def blueprints_find():
|
||||
"""Find blueprints and return valid ones"""
|
||||
blueprints = []
|
||||
for file in glob(f"{CONFIG.y('blueprints_dir')}/**/*.yaml", recursive=True):
|
||||
path = Path(file)
|
||||
with open(path, "r", encoding="utf-8") as blueprint_file:
|
||||
try:
|
||||
raw_blueprint = load(blueprint_file.read(), BlueprintLoader)
|
||||
except YAMLError:
|
||||
raw_blueprint = None
|
||||
if not raw_blueprint:
|
||||
continue
|
||||
metadata = raw_blueprint.get("metadata", None)
|
||||
version = raw_blueprint.get("version", 1)
|
||||
if version != 1:
|
||||
continue
|
||||
file_hash = sha512(path.read_bytes()).hexdigest()
|
||||
blueprint = BlueprintFile(str(path), version, file_hash, path.stat().st_mtime)
|
||||
blueprint.meta = from_dict(BlueprintMetadata, metadata) if metadata else None
|
||||
if (
|
||||
blueprint.meta
|
||||
and blueprint.meta.labels.get(LABEL_AUTHENTIK_EXAMPLE, "").lower() == "true"
|
||||
):
|
||||
continue
|
||||
blueprints.append(blueprint)
|
||||
return blueprints
|
||||
|
||||
|
||||
@CELERY_APP.task(
|
||||
throws=(DatabaseError, ProgrammingError, InternalError), base=MonitoredTask, bind=True
|
||||
)
|
||||
@prefill_task
|
||||
def blueprints_discover():
|
||||
def blueprints_discover(self: MonitoredTask):
|
||||
"""Find blueprints and check if they need to be created in the database"""
|
||||
for folder in CONFIG.y("blueprint_locations"):
|
||||
for file in glob(f"{folder}/**/*.yaml", recursive=True):
|
||||
check_blueprint_v1_file(Path(file))
|
||||
count = 0
|
||||
for blueprint in blueprints_find():
|
||||
check_blueprint_v1_file(blueprint)
|
||||
count += 1
|
||||
self.set_status(
|
||||
TaskResult(
|
||||
TaskResultStatus.SUCCESSFUL,
|
||||
messages=[_("Successfully imported %(count)d files." % {"count": count})],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def check_blueprint_v1_file(path: Path):
|
||||
def check_blueprint_v1_file(blueprint: BlueprintFile):
|
||||
"""Check if blueprint should be imported"""
|
||||
with open(path, "r", encoding="utf-8") as blueprint_file:
|
||||
raw_blueprint = load(blueprint_file.read(), BlueprintLoader)
|
||||
version = raw_blueprint.get("version", 1)
|
||||
if version != 1:
|
||||
return
|
||||
blueprint_file.seek(0)
|
||||
file_hash = sha512(path.read_bytes()).hexdigest()
|
||||
instance: BlueprintInstance = BlueprintInstance.objects.filter(path=path).first()
|
||||
rel_path = Path(blueprint.path).relative_to(Path(CONFIG.y("blueprints_dir")))
|
||||
instance: BlueprintInstance = BlueprintInstance.objects.filter(path=blueprint.path).first()
|
||||
if not instance:
|
||||
instance = BlueprintInstance(
|
||||
name=path.name,
|
||||
path=str(path),
|
||||
name=blueprint.meta.name if blueprint.meta else str(rel_path),
|
||||
path=blueprint.path,
|
||||
context={},
|
||||
status=BlueprintInstanceStatus.UNKNOWN,
|
||||
enabled=True,
|
||||
managed_models=[],
|
||||
metadata={},
|
||||
)
|
||||
instance.save()
|
||||
if instance.last_applied_hash != file_hash:
|
||||
apply_blueprint.delay(instance.pk.hex)
|
||||
instance.last_applied_hash = file_hash
|
||||
if instance.last_applied_hash != blueprint.hash:
|
||||
instance.metadata = asdict(blueprint.meta) if blueprint.meta else {}
|
||||
instance.save()
|
||||
apply_blueprint.delay(instance.pk.hex)
|
||||
|
||||
|
||||
@CELERY_APP.task(
|
||||
|
@ -62,25 +113,33 @@ def check_blueprint_v1_file(path: Path):
|
|||
)
|
||||
def apply_blueprint(self: MonitoredTask, instance_pk: str):
|
||||
"""Apply single blueprint"""
|
||||
self.set_uid(instance_pk)
|
||||
self.save_on_success = False
|
||||
try:
|
||||
instance: BlueprintInstance = BlueprintInstance.objects.filter(pk=instance_pk).first()
|
||||
if not instance or not instance.enabled:
|
||||
return
|
||||
file_hash = sha512(Path(instance.path).read_bytes()).hexdigest()
|
||||
with open(instance.path, "r", encoding="utf-8") as blueprint_file:
|
||||
importer = Importer(blueprint_file.read())
|
||||
valid, logs = importer.validate()
|
||||
if not valid:
|
||||
instance.status = BlueprintInstanceStatus.ERROR
|
||||
instance.save()
|
||||
self.set_status(TaskResult(TaskResultStatus.ERROR, [x["event"] for x in logs]))
|
||||
return
|
||||
applied = importer.apply()
|
||||
if not applied:
|
||||
instance.status = BlueprintInstanceStatus.ERROR
|
||||
instance.save()
|
||||
self.set_status(TaskResult(TaskResultStatus.ERROR, "Failed to apply"))
|
||||
except (DatabaseError, ProgrammingError, InternalError) as exc:
|
||||
valid, logs = importer.validate()
|
||||
if not valid:
|
||||
instance.status = BlueprintInstanceStatus.ERROR
|
||||
instance.save()
|
||||
self.set_status(TaskResult(TaskResultStatus.ERROR, [x["event"] for x in logs]))
|
||||
return
|
||||
applied = importer.apply()
|
||||
if not applied:
|
||||
instance.status = BlueprintInstanceStatus.ERROR
|
||||
instance.save()
|
||||
self.set_status(TaskResult(TaskResultStatus.ERROR, "Failed to apply"))
|
||||
return
|
||||
instance.status = BlueprintInstanceStatus.SUCCESSFUL
|
||||
instance.last_applied_hash = file_hash
|
||||
instance.last_applied = now()
|
||||
instance.save()
|
||||
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL))
|
||||
except (DatabaseError, ProgrammingError, InternalError, IOError) as exc:
|
||||
instance.status = BlueprintInstanceStatus.ERROR
|
||||
instance.save()
|
||||
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = {}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -79,5 +79,4 @@ cert_discovery_dir: /certs
|
|||
default_token_length: 128
|
||||
impersonation: true
|
||||
|
||||
blueprint_locations:
|
||||
- /blueprints
|
||||
blueprints_dir: /blueprints
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
metadata:
|
||||
name: Default - Password change flow
|
||||
entries:
|
||||
- attrs:
|
||||
compatibility_mode: false
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
metadata:
|
||||
name: Default - Authentication flow
|
||||
entries:
|
||||
- attrs:
|
||||
cache_count: 1
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
metadata:
|
||||
name: Default - Invalidation flow
|
||||
entries:
|
||||
- attrs:
|
||||
compatibility_mode: false
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
metadata:
|
||||
name: Default - Static MFA setup flow
|
||||
entries:
|
||||
- attrs:
|
||||
compatibility_mode: false
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
metadata:
|
||||
name: Default - TOTP MFA setup flow
|
||||
entries:
|
||||
- attrs:
|
||||
compatibility_mode: false
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
metadata:
|
||||
name: Default - WebAuthn MFA setup flow
|
||||
entries:
|
||||
- attrs:
|
||||
compatibility_mode: false
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
metadata:
|
||||
name: Default - Provider authorization flow (explicit consent)
|
||||
entries:
|
||||
- attrs:
|
||||
compatibility_mode: false
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
metadata:
|
||||
name: Default - Provider authorization flow (implicit consent)
|
||||
entries:
|
||||
- attrs:
|
||||
compatibility_mode: false
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
metadata:
|
||||
name: Default - Source authentication flow
|
||||
entries:
|
||||
- attrs:
|
||||
compatibility_mode: false
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
metadata:
|
||||
name: Default - Source enrollment flow
|
||||
entries:
|
||||
- attrs:
|
||||
compatibility_mode: false
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
metadata:
|
||||
name: Default - Source pre-authentication flow
|
||||
entries:
|
||||
- attrs:
|
||||
compatibility_mode: false
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
metadata:
|
||||
name: Default - User settings flow
|
||||
entries:
|
||||
- attrs:
|
||||
compatibility_mode: false
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
metadata:
|
||||
name: Default - Tenant
|
||||
version: 1
|
||||
entries:
|
||||
- attrs:
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
version: 1
|
||||
metadata:
|
||||
labels:
|
||||
blueprints.goauthentik.io/example: "true"
|
||||
name: Example - Enrollment (2 Stage)
|
||||
entries:
|
||||
- identifiers:
|
||||
slug: default-enrollment-flow
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
version: 1
|
||||
metadata:
|
||||
labels:
|
||||
blueprints.goauthentik.io/example: "true"
|
||||
name: Example - Two-factor Login
|
||||
entries:
|
||||
- identifiers:
|
||||
slug: default-authentication-flow
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
version: 1
|
||||
metadata:
|
||||
labels:
|
||||
blueprints.goauthentik.io/example: "true"
|
||||
name: Example - User deletion
|
||||
entries:
|
||||
- identifiers:
|
||||
slug: default-unenrollment-flow
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
105
schema.yml
105
schema.yml
|
@ -6218,6 +6218,62 @@ paths:
|
|||
$ref: '#/components/schemas/ValidationError'
|
||||
'403':
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
/managed/blueprints/{instance_uuid}/apply/:
|
||||
post:
|
||||
operationId: managed_blueprints_apply_create
|
||||
description: Apply a blueprint
|
||||
parameters:
|
||||
- in: path
|
||||
name: instance_uuid
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: A UUID string identifying this Blueprint Instance.
|
||||
required: true
|
||||
tags:
|
||||
- managed
|
||||
security:
|
||||
- authentik: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BlueprintInstance'
|
||||
description: ''
|
||||
'400':
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
'403':
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
/managed/blueprints/{instance_uuid}/used_by/:
|
||||
get:
|
||||
operationId: managed_blueprints_used_by_list
|
||||
description: Get a list of all objects that use this object
|
||||
parameters:
|
||||
- in: path
|
||||
name: instance_uuid
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: A UUID string identifying this Blueprint Instance.
|
||||
required: true
|
||||
tags:
|
||||
- managed
|
||||
security:
|
||||
- authentik: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UsedBy'
|
||||
description: ''
|
||||
'400':
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
'403':
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
/managed/blueprints/available/:
|
||||
get:
|
||||
operationId: managed_blueprints_available_list
|
||||
|
@ -6233,7 +6289,7 @@ paths:
|
|||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
$ref: '#/components/schemas/BlueprintFile'
|
||||
description: ''
|
||||
'400':
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
|
@ -20862,6 +20918,25 @@ components:
|
|||
- POST
|
||||
- POST_AUTO
|
||||
type: string
|
||||
BlueprintFile:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
last_m:
|
||||
type: string
|
||||
format: date-time
|
||||
hash:
|
||||
type: string
|
||||
meta:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Metadata'
|
||||
readOnly: true
|
||||
required:
|
||||
- hash
|
||||
- last_m
|
||||
- meta
|
||||
- path
|
||||
BlueprintInstance:
|
||||
type: object
|
||||
description: Info about a single blueprint instance file
|
||||
|
@ -20886,7 +20961,9 @@ components:
|
|||
type: string
|
||||
readOnly: true
|
||||
status:
|
||||
$ref: '#/components/schemas/BlueprintInstanceStatusEnum'
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BlueprintInstanceStatusEnum'
|
||||
readOnly: true
|
||||
enabled:
|
||||
type: boolean
|
||||
managed_models:
|
||||
|
@ -20894,11 +20971,15 @@ components:
|
|||
items:
|
||||
type: string
|
||||
readOnly: true
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: {}
|
||||
readOnly: true
|
||||
required:
|
||||
- context
|
||||
- last_applied
|
||||
- last_applied_hash
|
||||
- managed_models
|
||||
- metadata
|
||||
- name
|
||||
- path
|
||||
- pk
|
||||
|
@ -20916,15 +20997,11 @@ components:
|
|||
context:
|
||||
type: object
|
||||
additionalProperties: {}
|
||||
status:
|
||||
$ref: '#/components/schemas/BlueprintInstanceStatusEnum'
|
||||
enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- context
|
||||
- name
|
||||
- path
|
||||
- status
|
||||
BlueprintInstanceStatusEnum:
|
||||
enum:
|
||||
- successful
|
||||
|
@ -23774,6 +23851,18 @@ components:
|
|||
required:
|
||||
- challenge
|
||||
- name
|
||||
Metadata:
|
||||
type: object
|
||||
description: Serializer for blueprint metadata
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
labels:
|
||||
type: object
|
||||
additionalProperties: {}
|
||||
required:
|
||||
- labels
|
||||
- name
|
||||
NameIdPolicyEnum:
|
||||
enum:
|
||||
- urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
|
||||
|
@ -27808,8 +27897,6 @@ components:
|
|||
context:
|
||||
type: object
|
||||
additionalProperties: {}
|
||||
status:
|
||||
$ref: '#/components/schemas/BlueprintInstanceStatusEnum'
|
||||
enabled:
|
||||
type: boolean
|
||||
PatchedCaptchaStageRequest:
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"""
|
||||
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
105
web/src/pages/blueprints/BlueprintForm.ts
Normal file
105
web/src/pages/blueprints/BlueprintForm.ts
Normal 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>`;
|
||||
}
|
||||
}
|
148
web/src/pages/blueprints/BlueprintListPage.ts
Normal file
148
web/src/pages/blueprints/BlueprintListPage.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -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>`,
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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>`;
|
||||
}),
|
||||
];
|
||||
|
|
Reference in a new issue