core: create app transactional api (#6446)

* initial api and schema

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* separate blueprint importer from yaml parsing

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* cleanup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add new "must_created" state to blueprints to prevent overwriting objects

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rework validation and error response to make it actually usable

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix lint errors

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add defaults

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rework transaction_rollback

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* use static method for string imports of subclass

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* slight cleanup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L 2023-09-17 23:55:21 +02:00 committed by GitHub
parent 583c5e3ba7
commit 7649a57495
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 607 additions and 192 deletions

View file

@ -49,7 +49,7 @@ class BlueprintInstanceSerializer(ModelSerializer):
if content == "": if content == "":
return content return content
context = self.instance.context if self.instance else {} context = self.instance.context if self.instance else {}
valid, logs = Importer(content, context).validate() valid, logs = Importer.from_string(content, context).validate()
if not valid: if not valid:
text_logs = "\n".join([x["event"] for x in logs]) text_logs = "\n".join([x["event"] for x in logs])
raise ValidationError(_("Failed to validate blueprint: %(logs)s" % {"logs": text_logs})) raise ValidationError(_("Failed to validate blueprint: %(logs)s" % {"logs": text_logs}))

View file

@ -18,7 +18,7 @@ class Command(BaseCommand):
"""Apply all blueprints in order, abort when one fails to import""" """Apply all blueprints in order, abort when one fails to import"""
for blueprint_path in options.get("blueprints", []): for blueprint_path in options.get("blueprints", []):
content = BlueprintInstance(path=blueprint_path).retrieve() content = BlueprintInstance(path=blueprint_path).retrieve()
importer = Importer(content) importer = Importer.from_string(content)
valid, _ = importer.validate() valid, _ = importer.validate()
if not valid: if not valid:
self.stderr.write("blueprint invalid") self.stderr.write("blueprint invalid")

View file

@ -9,6 +9,7 @@ from rest_framework.fields import Field, JSONField, UUIDField
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.blueprints.v1.common import BlueprintEntryDesiredState
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT, is_model_allowed from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT, is_model_allowed
from authentik.blueprints.v1.meta.registry import BaseMetaModel, registry from authentik.blueprints.v1.meta.registry import BaseMetaModel, registry
from authentik.lib.models import SerializerModel from authentik.lib.models import SerializerModel
@ -110,7 +111,7 @@ class Command(BaseCommand):
"id": {"type": "string"}, "id": {"type": "string"},
"state": { "state": {
"type": "string", "type": "string",
"enum": ["absent", "present", "created"], "enum": [s.value for s in BlueprintEntryDesiredState],
"default": "present", "default": "present",
}, },
"conditions": {"type": "array", "items": {"type": "boolean"}}, "conditions": {"type": "array", "items": {"type": "boolean"}},

View file

@ -20,7 +20,7 @@ def apply_blueprint(*files: str):
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
for file in files: for file in files:
content = BlueprintInstance(path=file).retrieve() content = BlueprintInstance(path=file).retrieve()
Importer(content).apply() Importer.from_string(content).apply()
return func(*args, **kwargs) return func(*args, **kwargs)
return wrapper return wrapper

View file

@ -25,7 +25,7 @@ def blueprint_tester(file_name: Path) -> Callable:
def tester(self: TestPackaged): def tester(self: TestPackaged):
base = Path("blueprints/") base = Path("blueprints/")
rel_path = Path(file_name).relative_to(base) rel_path = Path(file_name).relative_to(base)
importer = Importer(BlueprintInstance(path=str(rel_path)).retrieve()) importer = Importer.from_string(BlueprintInstance(path=str(rel_path)).retrieve())
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())

View file

@ -21,14 +21,14 @@ class TestBlueprintsV1(TransactionTestCase):
def test_blueprint_invalid_format(self): def test_blueprint_invalid_format(self):
"""Test blueprint with invalid format""" """Test blueprint with invalid format"""
importer = Importer('{"version": 3}') importer = Importer.from_string('{"version": 3}')
self.assertFalse(importer.validate()[0]) self.assertFalse(importer.validate()[0])
importer = Importer( importer = Importer.from_string(
'{"version": 1,"entries":[{"identifiers":{},"attrs":{},' '{"version": 1,"entries":[{"identifiers":{},"attrs":{},'
'"model": "authentik_core.User"}]}' '"model": "authentik_core.User"}]}'
) )
self.assertFalse(importer.validate()[0]) self.assertFalse(importer.validate()[0])
importer = Importer( importer = Importer.from_string(
'{"version": 1, "entries": [{"attrs": {"name": "test"}, ' '{"version": 1, "entries": [{"attrs": {"name": "test"}, '
'"identifiers": {}, ' '"identifiers": {}, '
'"model": "authentik_core.Group"}]}' '"model": "authentik_core.Group"}]}'
@ -54,7 +54,7 @@ class TestBlueprintsV1(TransactionTestCase):
}, },
) )
importer = Importer( importer = Importer.from_string(
'{"version": 1, "entries": [{"attrs": {"name": "test999", "attributes": ' '{"version": 1, "entries": [{"attrs": {"name": "test999", "attributes": '
'{"key": ["updated_value"]}}, "identifiers": {"attributes": {"other_key": ' '{"key": ["updated_value"]}}, "identifiers": {"attributes": {"other_key": '
'["other_value"]}}, "model": "authentik_core.Group"}]}' '["other_value"]}}, "model": "authentik_core.Group"}]}'
@ -103,7 +103,7 @@ class TestBlueprintsV1(TransactionTestCase):
self.assertEqual(len(export.entries), 3) self.assertEqual(len(export.entries), 3)
export_yaml = exporter.export_to_string() export_yaml = exporter.export_to_string()
importer = Importer(export_yaml) importer = Importer.from_string(export_yaml)
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())
@ -113,14 +113,14 @@ class TestBlueprintsV1(TransactionTestCase):
"""Test export and import it twice""" """Test export and import it twice"""
count_initial = Prompt.objects.filter(field_key="username").count() count_initial = Prompt.objects.filter(field_key="username").count()
importer = Importer(load_fixture("fixtures/static_prompt_export.yaml")) importer = Importer.from_string(load_fixture("fixtures/static_prompt_export.yaml"))
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())
count_before = Prompt.objects.filter(field_key="username").count() count_before = Prompt.objects.filter(field_key="username").count()
self.assertEqual(count_initial + 1, count_before) self.assertEqual(count_initial + 1, count_before)
importer = Importer(load_fixture("fixtures/static_prompt_export.yaml")) importer = Importer.from_string(load_fixture("fixtures/static_prompt_export.yaml"))
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())
self.assertEqual(Prompt.objects.filter(field_key="username").count(), count_before) self.assertEqual(Prompt.objects.filter(field_key="username").count(), count_before)
@ -130,7 +130,7 @@ class TestBlueprintsV1(TransactionTestCase):
ExpressionPolicy.objects.filter(name="foo-bar-baz-qux").delete() ExpressionPolicy.objects.filter(name="foo-bar-baz-qux").delete()
Group.objects.filter(name="test").delete() Group.objects.filter(name="test").delete()
environ["foo"] = generate_id() environ["foo"] = generate_id()
importer = Importer(load_fixture("fixtures/tags.yaml"), {"bar": "baz"}) importer = Importer.from_string(load_fixture("fixtures/tags.yaml"), {"bar": "baz"})
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())
policy = ExpressionPolicy.objects.filter(name="foo-bar-baz-qux").first() policy = ExpressionPolicy.objects.filter(name="foo-bar-baz-qux").first()
@ -248,7 +248,7 @@ class TestBlueprintsV1(TransactionTestCase):
exporter = FlowExporter(flow) exporter = FlowExporter(flow)
export_yaml = exporter.export_to_string() export_yaml = exporter.export_to_string()
importer = Importer(export_yaml) importer = Importer.from_string(export_yaml)
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())
self.assertTrue(UserLoginStage.objects.filter(name=stage_name).exists()) self.assertTrue(UserLoginStage.objects.filter(name=stage_name).exists())
@ -297,7 +297,7 @@ class TestBlueprintsV1(TransactionTestCase):
exporter = FlowExporter(flow) exporter = FlowExporter(flow)
export_yaml = exporter.export_to_string() export_yaml = exporter.export_to_string()
importer = Importer(export_yaml) importer = Importer.from_string(export_yaml)
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())

View file

@ -18,7 +18,7 @@ class TestBlueprintsV1ConditionalFields(TransactionTestCase):
self.uid = generate_id() self.uid = generate_id()
import_yaml = load_fixture("fixtures/conditional_fields.yaml", uid=self.uid, user=user.pk) import_yaml = load_fixture("fixtures/conditional_fields.yaml", uid=self.uid, user=user.pk)
importer = Importer(import_yaml) importer = Importer.from_string(import_yaml)
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())

View file

@ -18,7 +18,7 @@ class TestBlueprintsV1Conditions(TransactionTestCase):
"fixtures/conditions_fulfilled.yaml", id1=flow_slug1, id2=flow_slug2 "fixtures/conditions_fulfilled.yaml", id1=flow_slug1, id2=flow_slug2
) )
importer = Importer(import_yaml) importer = Importer.from_string(import_yaml)
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())
# Ensure objects exist # Ensure objects exist
@ -35,7 +35,7 @@ class TestBlueprintsV1Conditions(TransactionTestCase):
"fixtures/conditions_not_fulfilled.yaml", id1=flow_slug1, id2=flow_slug2 "fixtures/conditions_not_fulfilled.yaml", id1=flow_slug1, id2=flow_slug2
) )
importer = Importer(import_yaml) importer = Importer.from_string(import_yaml)
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())
# Ensure objects do not exist # Ensure objects do not exist

View file

@ -15,7 +15,7 @@ class TestBlueprintsV1State(TransactionTestCase):
flow_slug = generate_id() flow_slug = generate_id()
import_yaml = load_fixture("fixtures/state_present.yaml", id=flow_slug) import_yaml = load_fixture("fixtures/state_present.yaml", id=flow_slug)
importer = Importer(import_yaml) importer = Importer.from_string(import_yaml)
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())
# Ensure object exists # Ensure object exists
@ -30,7 +30,7 @@ class TestBlueprintsV1State(TransactionTestCase):
self.assertEqual(flow.title, "bar") self.assertEqual(flow.title, "bar")
# Ensure importer updates it # Ensure importer updates it
importer = Importer(import_yaml) importer = Importer.from_string(import_yaml)
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())
flow: Flow = Flow.objects.filter(slug=flow_slug).first() flow: Flow = Flow.objects.filter(slug=flow_slug).first()
@ -41,7 +41,7 @@ class TestBlueprintsV1State(TransactionTestCase):
flow_slug = generate_id() flow_slug = generate_id()
import_yaml = load_fixture("fixtures/state_created.yaml", id=flow_slug) import_yaml = load_fixture("fixtures/state_created.yaml", id=flow_slug)
importer = Importer(import_yaml) importer = Importer.from_string(import_yaml)
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())
# Ensure object exists # Ensure object exists
@ -56,7 +56,7 @@ class TestBlueprintsV1State(TransactionTestCase):
self.assertEqual(flow.title, "bar") self.assertEqual(flow.title, "bar")
# Ensure importer doesn't update it # Ensure importer doesn't update it
importer = Importer(import_yaml) importer = Importer.from_string(import_yaml)
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())
flow: Flow = Flow.objects.filter(slug=flow_slug).first() flow: Flow = Flow.objects.filter(slug=flow_slug).first()
@ -67,7 +67,7 @@ class TestBlueprintsV1State(TransactionTestCase):
flow_slug = generate_id() flow_slug = generate_id()
import_yaml = load_fixture("fixtures/state_created.yaml", id=flow_slug) import_yaml = load_fixture("fixtures/state_created.yaml", id=flow_slug)
importer = Importer(import_yaml) importer = Importer.from_string(import_yaml)
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())
# Ensure object exists # Ensure object exists
@ -75,7 +75,7 @@ class TestBlueprintsV1State(TransactionTestCase):
self.assertEqual(flow.slug, flow_slug) self.assertEqual(flow.slug, flow_slug)
import_yaml = load_fixture("fixtures/state_absent.yaml", id=flow_slug) import_yaml = load_fixture("fixtures/state_absent.yaml", id=flow_slug)
importer = Importer(import_yaml) importer = Importer.from_string(import_yaml)
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())
flow: Flow = Flow.objects.filter(slug=flow_slug).first() flow: Flow = Flow.objects.filter(slug=flow_slug).first()

View file

@ -12,6 +12,7 @@ from uuid import UUID
from deepmerge import always_merger from deepmerge import always_merger
from django.apps import apps from django.apps import apps
from django.db.models import Model, Q from django.db.models import Model, Q
from rest_framework.exceptions import ValidationError
from rest_framework.fields import Field from rest_framework.fields import Field
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
from yaml import SafeDumper, SafeLoader, ScalarNode, SequenceNode from yaml import SafeDumper, SafeLoader, ScalarNode, SequenceNode
@ -52,6 +53,7 @@ class BlueprintEntryDesiredState(Enum):
ABSENT = "absent" ABSENT = "absent"
PRESENT = "present" PRESENT = "present"
CREATED = "created" CREATED = "created"
MUST_CREATED = "must_created"
@dataclass @dataclass
@ -206,8 +208,8 @@ class KeyOf(YAMLTag):
): ):
return _entry._state.instance.pbm_uuid return _entry._state.instance.pbm_uuid
return _entry._state.instance.pk return _entry._state.instance.pk
raise EntryInvalidError( raise EntryInvalidError.from_entry(
f"KeyOf: failed to find entry with `id` of `{self.id_from}` and a model instance" f"KeyOf: failed to find entry with `id` of `{self.id_from}` and a model instance", entry
) )
@ -278,7 +280,7 @@ class Format(YAMLTag):
try: try:
return self.format_string % tuple(args) return self.format_string % tuple(args)
except TypeError as exc: except TypeError as exc:
raise EntryInvalidError(exc) raise EntryInvalidError.from_entry(exc, entry)
class Find(YAMLTag): class Find(YAMLTag):
@ -355,13 +357,15 @@ class Condition(YAMLTag):
args.append(arg) args.append(arg)
if not args: if not args:
raise EntryInvalidError("At least one value is required after mode selection.") raise EntryInvalidError.from_entry(
"At least one value is required after mode selection.", entry
)
try: try:
comparator = self._COMPARATORS[self.mode.upper()] comparator = self._COMPARATORS[self.mode.upper()]
return comparator(tuple(bool(x) for x in args)) return comparator(tuple(bool(x) for x in args))
except (TypeError, KeyError) as exc: except (TypeError, KeyError) as exc:
raise EntryInvalidError(exc) raise EntryInvalidError.from_entry(exc, entry)
class If(YAMLTag): class If(YAMLTag):
@ -393,7 +397,7 @@ class If(YAMLTag):
blueprint, blueprint,
) )
except TypeError as exc: except TypeError as exc:
raise EntryInvalidError(exc) raise EntryInvalidError.from_entry(exc, entry)
class Enumerate(YAMLTag, YAMLTagContext): class Enumerate(YAMLTag, YAMLTagContext):
@ -425,9 +429,10 @@ class Enumerate(YAMLTag, YAMLTagContext):
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
if isinstance(self.iterable, EnumeratedItem) and self.iterable.depth == 0: if isinstance(self.iterable, EnumeratedItem) and self.iterable.depth == 0:
raise EntryInvalidError( raise EntryInvalidError.from_entry(
f"{self.__class__.__name__} tag's iterable references this tag's context. " f"{self.__class__.__name__} tag's iterable references this tag's context. "
"This is a noop. Check you are setting depth bigger than 0." "This is a noop. Check you are setting depth bigger than 0.",
entry,
) )
if isinstance(self.iterable, YAMLTag): if isinstance(self.iterable, YAMLTag):
@ -436,9 +441,10 @@ class Enumerate(YAMLTag, YAMLTagContext):
iterable = self.iterable iterable = self.iterable
if not isinstance(iterable, Iterable): if not isinstance(iterable, Iterable):
raise EntryInvalidError( raise EntryInvalidError.from_entry(
f"{self.__class__.__name__}'s iterable must be an iterable " f"{self.__class__.__name__}'s iterable must be an iterable "
"such as a sequence or a mapping" "such as a sequence or a mapping",
entry,
) )
if isinstance(iterable, Mapping): if isinstance(iterable, Mapping):
@ -449,7 +455,7 @@ class Enumerate(YAMLTag, YAMLTagContext):
try: try:
output_class, add_fn = self._OUTPUT_BODIES[self.output_body.upper()] output_class, add_fn = self._OUTPUT_BODIES[self.output_body.upper()]
except KeyError as exc: except KeyError as exc:
raise EntryInvalidError(exc) raise EntryInvalidError.from_entry(exc, entry)
result = output_class() result = output_class()
@ -461,8 +467,8 @@ class Enumerate(YAMLTag, YAMLTagContext):
resolved_body = entry.tag_resolver(self.item_body, blueprint) resolved_body = entry.tag_resolver(self.item_body, blueprint)
result = add_fn(result, resolved_body) result = add_fn(result, resolved_body)
if not isinstance(result, output_class): if not isinstance(result, output_class):
raise EntryInvalidError( raise EntryInvalidError.from_entry(
f"Invalid {self.__class__.__name__} item found: {resolved_body}" f"Invalid {self.__class__.__name__} item found: {resolved_body}", entry
) )
finally: finally:
self.__current_context = tuple() self.__current_context = tuple()
@ -489,12 +495,13 @@ class EnumeratedItem(YAMLTag):
) )
except ValueError as exc: except ValueError as exc:
if self.depth == 0: if self.depth == 0:
raise EntryInvalidError( raise EntryInvalidError.from_entry(
f"{self.__class__.__name__} tags are only usable " f"{self.__class__.__name__} tags are only usable "
f"inside an {Enumerate.__name__} tag" f"inside an {Enumerate.__name__} tag",
entry,
) )
raise EntryInvalidError(f"{self.__class__.__name__} tag: {exc}") raise EntryInvalidError.from_entry(f"{self.__class__.__name__} tag: {exc}", entry)
return context_tag.get_context(entry, blueprint) return context_tag.get_context(entry, blueprint)
@ -508,7 +515,7 @@ class Index(EnumeratedItem):
try: try:
return context[0] return context[0]
except IndexError: # pragma: no cover except IndexError: # pragma: no cover
raise EntryInvalidError(f"Empty/invalid context: {context}") raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry)
class Value(EnumeratedItem): class Value(EnumeratedItem):
@ -520,7 +527,7 @@ class Value(EnumeratedItem):
try: try:
return context[1] return context[1]
except IndexError: # pragma: no cover except IndexError: # pragma: no cover
raise EntryInvalidError(f"Empty/invalid context: {context}") raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry)
class BlueprintDumper(SafeDumper): class BlueprintDumper(SafeDumper):
@ -574,8 +581,26 @@ class BlueprintLoader(SafeLoader):
class EntryInvalidError(SentryIgnoredException): class EntryInvalidError(SentryIgnoredException):
"""Error raised when an entry is invalid""" """Error raised when an entry is invalid"""
serializer_errors: Optional[dict] entry_model: Optional[str]
entry_id: Optional[str]
validation_error: Optional[ValidationError]
def __init__(self, *args: object, serializer_errors: Optional[dict] = None) -> None: def __init__(self, *args: object, validation_error: Optional[ValidationError] = None) -> None:
super().__init__(*args) super().__init__(*args)
self.serializer_errors = serializer_errors self.entry_model = None
self.entry_id = None
self.validation_error = validation_error
@staticmethod
def from_entry(
msg_or_exc: str | Exception, entry: BlueprintEntry, *args, **kwargs
) -> "EntryInvalidError":
"""Create EntryInvalidError with the context of an entry"""
error = EntryInvalidError(msg_or_exc, *args, **kwargs)
if isinstance(msg_or_exc, ValidationError):
error.validation_error = msg_or_exc
# Make sure the model and id are strings, depending where the error happens
# they might still be YAMLTag instances
error.entry_model = str(entry.model)
error.entry_id = str(entry.id)
return error

View file

@ -8,9 +8,9 @@ from dacite.core import from_dict
from dacite.exceptions import DaciteError from dacite.exceptions import DaciteError
from deepmerge import always_merger from deepmerge import always_merger
from django.core.exceptions import FieldError from django.core.exceptions import FieldError
from django.db import transaction
from django.db.models import Model from django.db.models import Model
from django.db.models.query_utils import Q from django.db.models.query_utils import Q
from django.db.transaction import atomic
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.serializers import BaseSerializer, Serializer from rest_framework.serializers import BaseSerializer, Serializer
@ -38,6 +38,7 @@ from authentik.core.models import (
from authentik.events.utils import cleanse_dict from authentik.events.utils import cleanse_dict
from authentik.flows.models import FlowToken, Stage from authentik.flows.models import FlowToken, Stage
from authentik.lib.models import SerializerModel from authentik.lib.models import SerializerModel
from authentik.lib.sentry import SentryIgnoredException
from authentik.outposts.models import OutpostServiceConnection from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel from authentik.policies.models import Policy, PolicyBindingModel
@ -72,41 +73,53 @@ def is_model_allowed(model: type[Model]) -> bool:
return model not in excluded_models and issubclass(model, (SerializerModel, BaseMetaModel)) return model not in excluded_models and issubclass(model, (SerializerModel, BaseMetaModel))
class DoRollback(SentryIgnoredException):
"""Exception to trigger a rollback"""
@contextmanager @contextmanager
def transaction_rollback(): def transaction_rollback():
"""Enters an atomic transaction and always triggers a rollback at the end of the block.""" """Enters an atomic transaction and always triggers a rollback at the end of the block."""
atomic = transaction.atomic() try:
# pylint: disable=unnecessary-dunder-call with atomic():
atomic.__enter__()
yield yield
atomic.__exit__(IntegrityError, None, None) raise DoRollback()
except DoRollback:
pass
class Importer: class Importer:
"""Import Blueprint from YAML""" """Import Blueprint from raw dict or YAML/JSON"""
logger: BoundLogger logger: BoundLogger
_import: Blueprint
def __init__(self, yaml_input: str, context: Optional[dict] = None): def __init__(self, blueprint: Blueprint, context: Optional[dict] = None):
self.__pk_map: dict[Any, Model] = {} self.__pk_map: dict[Any, Model] = {}
self._import = blueprint
self.logger = get_logger() self.logger = get_logger()
ctx = {}
always_merger.merge(ctx, self._import.context)
if context:
always_merger.merge(ctx, context)
self._import.context = ctx
@staticmethod
def from_string(yaml_input: str, context: dict | None = None) -> "Importer":
"""Parse YAML string and create blueprint importer from it"""
import_dict = load(yaml_input, BlueprintLoader) import_dict = load(yaml_input, BlueprintLoader)
try: try:
self.__import = from_dict( _import = from_dict(
Blueprint, import_dict, config=Config(cast=[BlueprintEntryDesiredState]) Blueprint, import_dict, config=Config(cast=[BlueprintEntryDesiredState])
) )
except DaciteError as exc: except DaciteError as exc:
raise EntryInvalidError from exc raise EntryInvalidError from exc
ctx = {} return Importer(_import, context)
always_merger.merge(ctx, self.__import.context)
if context:
always_merger.merge(ctx, context)
self.__import.context = ctx
@property @property
def blueprint(self) -> Blueprint: def blueprint(self) -> Blueprint:
"""Get imported blueprint""" """Get imported blueprint"""
return self.__import return self._import
def __update_pks_for_attrs(self, attrs: dict[str, Any]) -> dict[str, Any]: def __update_pks_for_attrs(self, attrs: dict[str, Any]) -> dict[str, Any]:
"""Replace any value if it is a known primary key of an other object""" """Replace any value if it is a known primary key of an other object"""
@ -152,19 +165,19 @@ class Importer:
# pylint: disable-msg=too-many-locals # pylint: disable-msg=too-many-locals
def _validate_single(self, entry: BlueprintEntry) -> Optional[BaseSerializer]: def _validate_single(self, entry: BlueprintEntry) -> Optional[BaseSerializer]:
"""Validate a single entry""" """Validate a single entry"""
if not entry.check_all_conditions_match(self.__import): if not entry.check_all_conditions_match(self._import):
self.logger.debug("One or more conditions of this entry are not fulfilled, skipping") self.logger.debug("One or more conditions of this entry are not fulfilled, skipping")
return None return None
model_app_label, model_name = entry.get_model(self.__import).split(".") model_app_label, model_name = entry.get_model(self._import).split(".")
model: type[SerializerModel] = registry.get_model(model_app_label, model_name) model: type[SerializerModel] = registry.get_model(model_app_label, model_name)
# Don't use isinstance since we don't want to check for inheritance # Don't use isinstance since we don't want to check for inheritance
if not is_model_allowed(model): if not is_model_allowed(model):
raise EntryInvalidError(f"Model {model} not allowed") raise EntryInvalidError.from_entry(f"Model {model} not allowed", entry)
if issubclass(model, BaseMetaModel): if issubclass(model, BaseMetaModel):
serializer_class: type[Serializer] = model.serializer() serializer_class: type[Serializer] = model.serializer()
serializer = serializer_class( serializer = serializer_class(
data=entry.get_attrs(self.__import), data=entry.get_attrs(self._import),
context={ context={
SERIALIZER_CONTEXT_BLUEPRINT: entry, SERIALIZER_CONTEXT_BLUEPRINT: entry,
}, },
@ -172,8 +185,10 @@ class Importer:
try: try:
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
except ValidationError as exc: except ValidationError as exc:
raise EntryInvalidError( raise EntryInvalidError.from_entry(
f"Serializer errors {serializer.errors}", serializer_errors=serializer.errors f"Serializer errors {serializer.errors}",
validation_error=exc,
entry=entry,
) from exc ) from exc
return serializer return serializer
@ -182,7 +197,7 @@ class Importer:
# the full serializer for later usage # the full serializer for later usage
# Because a model might have multiple unique columns, we chain all identifiers together # Because a model might have multiple unique columns, we chain all identifiers together
# to create an OR query. # to create an OR query.
updated_identifiers = self.__update_pks_for_attrs(entry.get_identifiers(self.__import)) updated_identifiers = self.__update_pks_for_attrs(entry.get_identifiers(self._import))
for key, value in list(updated_identifiers.items()): for key, value in list(updated_identifiers.items()):
if isinstance(value, dict) and "pk" in value: if isinstance(value, dict) and "pk" in value:
del updated_identifiers[key] del updated_identifiers[key]
@ -190,12 +205,12 @@ class Importer:
query = self.__query_from_identifier(updated_identifiers) query = self.__query_from_identifier(updated_identifiers)
if not query: if not query:
raise EntryInvalidError("No or invalid identifiers") raise EntryInvalidError.from_entry("No or invalid identifiers", entry)
try: try:
existing_models = model.objects.filter(query) existing_models = model.objects.filter(query)
except FieldError as exc: except FieldError as exc:
raise EntryInvalidError(f"Invalid identifier field: {exc}") from exc raise EntryInvalidError.from_entry(f"Invalid identifier field: {exc}", entry) from exc
serializer_kwargs = {} serializer_kwargs = {}
model_instance = existing_models.first() model_instance = existing_models.first()
@ -208,6 +223,14 @@ class Importer:
) )
serializer_kwargs["instance"] = model_instance serializer_kwargs["instance"] = model_instance
serializer_kwargs["partial"] = True serializer_kwargs["partial"] = True
elif model_instance and entry.state == BlueprintEntryDesiredState.MUST_CREATED:
raise EntryInvalidError.from_entry(
(
f"state is set to {BlueprintEntryDesiredState.MUST_CREATED} "
"and object exists already",
),
entry,
)
else: else:
self.logger.debug( self.logger.debug(
"initialised new serializer instance", "initialised new serializer instance",
@ -220,9 +243,9 @@ class Importer:
model_instance.pk = updated_identifiers["pk"] model_instance.pk = updated_identifiers["pk"]
serializer_kwargs["instance"] = model_instance serializer_kwargs["instance"] = model_instance
try: try:
full_data = self.__update_pks_for_attrs(entry.get_attrs(self.__import)) full_data = self.__update_pks_for_attrs(entry.get_attrs(self._import))
except ValueError as exc: except ValueError as exc:
raise EntryInvalidError(exc) from exc raise EntryInvalidError.from_entry(exc, entry) from exc
always_merger.merge(full_data, updated_identifiers) always_merger.merge(full_data, updated_identifiers)
serializer_kwargs["data"] = full_data serializer_kwargs["data"] = full_data
@ -235,15 +258,17 @@ class Importer:
try: try:
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
except ValidationError as exc: except ValidationError as exc:
raise EntryInvalidError( raise EntryInvalidError.from_entry(
f"Serializer errors {serializer.errors}", serializer_errors=serializer.errors f"Serializer errors {serializer.errors}",
validation_error=exc,
entry=entry,
) from exc ) from exc
return serializer return serializer
def apply(self) -> bool: def apply(self) -> bool:
"""Apply (create/update) models yaml, in database transaction""" """Apply (create/update) models yaml, in database transaction"""
try: try:
with transaction.atomic(): with atomic():
if not self._apply_models(): if not self._apply_models():
self.logger.debug("Reverting changes due to error") self.logger.debug("Reverting changes due to error")
raise IntegrityError raise IntegrityError
@ -252,11 +277,11 @@ class Importer:
self.logger.debug("Committing changes") self.logger.debug("Committing changes")
return True return True
def _apply_models(self) -> bool: def _apply_models(self, raise_errors=False) -> bool:
"""Apply (create/update) models yaml""" """Apply (create/update) models yaml"""
self.__pk_map = {} self.__pk_map = {}
for entry in self.__import.entries: for entry in self._import.entries:
model_app_label, model_name = entry.get_model(self.__import).split(".") model_app_label, model_name = entry.get_model(self._import).split(".")
try: try:
model: type[SerializerModel] = registry.get_model(model_app_label, model_name) model: type[SerializerModel] = registry.get_model(model_app_label, model_name)
except LookupError: except LookupError:
@ -269,15 +294,21 @@ class Importer:
serializer = self._validate_single(entry) serializer = self._validate_single(entry)
except EntryInvalidError as exc: except EntryInvalidError as exc:
# For deleting objects we don't need the serializer to be valid # For deleting objects we don't need the serializer to be valid
if entry.get_state(self.__import) == BlueprintEntryDesiredState.ABSENT: if entry.get_state(self._import) == BlueprintEntryDesiredState.ABSENT:
continue continue
self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc) self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc)
if raise_errors:
raise exc
return False return False
if not serializer: if not serializer:
continue continue
state = entry.get_state(self.__import) state = entry.get_state(self._import)
if state in [BlueprintEntryDesiredState.PRESENT, BlueprintEntryDesiredState.CREATED]: if state in [
BlueprintEntryDesiredState.PRESENT,
BlueprintEntryDesiredState.CREATED,
BlueprintEntryDesiredState.MUST_CREATED,
]:
instance = serializer.instance instance = serializer.instance
if ( if (
instance instance
@ -305,23 +336,23 @@ class Importer:
self.logger.debug("entry to delete with no instance, skipping") self.logger.debug("entry to delete with no instance, skipping")
return True return True
def validate(self) -> tuple[bool, list[EventDict]]: def validate(self, raise_validation_errors=False) -> tuple[bool, list[EventDict]]:
"""Validate loaded blueprint export, ensure all models are allowed """Validate loaded blueprint export, ensure all models are allowed
and serializers have no errors""" and serializers have no errors"""
self.logger.debug("Starting blueprint import validation") self.logger.debug("Starting blueprint import validation")
orig_import = deepcopy(self.__import) orig_import = deepcopy(self._import)
if self.__import.version != 1: if self._import.version != 1:
self.logger.warning("Invalid blueprint version") self.logger.warning("Invalid blueprint version")
return False, [{"event": "Invalid blueprint version"}] return False, [{"event": "Invalid blueprint version"}]
with ( with (
transaction_rollback(), transaction_rollback(),
capture_logs() as logs, capture_logs() as logs,
): ):
successful = self._apply_models() successful = self._apply_models(raise_errors=raise_validation_errors)
if not successful: if not successful:
self.logger.debug("Blueprint validation failed") self.logger.debug("Blueprint validation failed")
for log in logs: for log in logs:
getattr(self.logger, log.get("log_level"))(**log) getattr(self.logger, log.get("log_level"))(**log)
self.logger.debug("Finished blueprint import validation") self.logger.debug("Finished blueprint import validation")
self.__import = orig_import self._import = orig_import
return successful, logs return successful, logs

View file

@ -190,7 +190,7 @@ def apply_blueprint(self: MonitoredTask, instance_pk: str):
self.set_uid(slugify(instance.name)) self.set_uid(slugify(instance.name))
blueprint_content = instance.retrieve() blueprint_content = instance.retrieve()
file_hash = sha512(blueprint_content.encode()).hexdigest() file_hash = sha512(blueprint_content.encode()).hexdigest()
importer = Importer(blueprint_content, instance.context) importer = Importer.from_string(blueprint_content, instance.context)
if importer.blueprint.metadata: if importer.blueprint.metadata:
instance.metadata = asdict(importer.blueprint.metadata) instance.metadata = asdict(importer.blueprint.metadata)
valid, logs = importer.validate() valid, logs = importer.validate()

View file

@ -0,0 +1,139 @@
"""transactional application and provider creation"""
from django.apps import apps
from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, extend_schema_field
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField, CharField, ChoiceField, DictField, ListField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from yaml import ScalarNode
from authentik.blueprints.v1.common import (
Blueprint,
BlueprintEntry,
BlueprintEntryDesiredState,
EntryInvalidError,
KeyOf,
)
from authentik.blueprints.v1.importer import Importer
from authentik.core.api.applications import ApplicationSerializer
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import Provider
from authentik.lib.utils.reflection import all_subclasses
def get_provider_serializer_mapping():
"""Get a mapping of all providers' model names and their serializers"""
mapping = {}
for model in all_subclasses(Provider):
if model._meta.abstract:
continue
mapping[f"{model._meta.app_label}.{model._meta.model_name}"] = model().serializer
return mapping
@extend_schema_field(
PolymorphicProxySerializer(
component_name="model",
serializers=get_provider_serializer_mapping,
resource_type_field_name="provider_model",
)
)
class TransactionProviderField(DictField):
"""Dictionary field which can hold provider creation data"""
class TransactionApplicationSerializer(PassiveSerializer):
"""Serializer for creating a provider and an application in one transaction"""
app = ApplicationSerializer()
provider_model = ChoiceField(choices=list(get_provider_serializer_mapping().keys()))
provider = TransactionProviderField()
_provider_model: type[Provider] = None
def validate_provider_model(self, fq_model_name: str) -> str:
"""Validate that the model exists and is a provider"""
if "." not in fq_model_name:
raise ValidationError("Invalid provider model")
try:
app, _, model_name = fq_model_name.partition(".")
model = apps.get_model(app, model_name)
if not issubclass(model, Provider):
raise ValidationError("Invalid provider model")
self._provider_model = model
except LookupError:
raise ValidationError("Invalid provider model")
return fq_model_name
def validate(self, attrs: dict) -> dict:
blueprint = Blueprint()
blueprint.entries.append(
BlueprintEntry(
model=attrs["provider_model"],
state=BlueprintEntryDesiredState.MUST_CREATED,
identifiers={
"name": attrs["provider"]["name"],
},
# Must match the name of the field on `self`
id="provider",
attrs=attrs["provider"],
)
)
app_data = attrs["app"]
app_data["provider"] = KeyOf(None, ScalarNode(tag="", value="provider"))
blueprint.entries.append(
BlueprintEntry(
model="authentik_core.application",
state=BlueprintEntryDesiredState.MUST_CREATED,
identifiers={
"slug": attrs["app"]["slug"],
},
attrs=app_data,
# Must match the name of the field on `self`
id="app",
)
)
importer = Importer(blueprint, {})
try:
valid, _ = importer.validate(raise_validation_errors=True)
if not valid:
raise ValidationError("Invalid blueprint")
except EntryInvalidError as exc:
raise ValidationError(
{
exc.entry_id: exc.validation_error.detail,
}
)
return blueprint
class TransactionApplicationResponseSerializer(PassiveSerializer):
"""Transactional creation response"""
applied = BooleanField()
logs = ListField(child=CharField())
class TransactionalApplicationView(APIView):
"""Create provider and application and attach them in a single transaction"""
permission_classes = [IsAdminUser]
@extend_schema(
request=TransactionApplicationSerializer(),
responses={
200: TransactionApplicationResponseSerializer(),
},
)
def put(self, request: Request) -> Response:
"""Convert data into a blueprint, validate it and apply it"""
data = TransactionApplicationSerializer(data=request.data)
data.is_valid(raise_exception=True)
importer = Importer(data.validated_data, {})
applied = importer.apply()
response = {"applied": False, "logs": []}
response["applied"] = applied
return Response(response, status=200)

View file

@ -0,0 +1,64 @@
"""Test Transactional API"""
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import OAuth2Provider
class TestTransactionalApplicationsAPI(APITestCase):
"""Test Transactional API"""
def setUp(self) -> None:
self.user = create_test_admin_user()
def test_create_transactional(self):
"""Test transactional Application + provider creation"""
self.client.force_login(self.user)
uid = generate_id()
authorization_flow = create_test_flow()
response = self.client.put(
reverse("authentik_api:core-transactional-application"),
data={
"app": {
"name": uid,
"slug": uid,
},
"provider_model": "authentik_providers_oauth2.oauth2provider",
"provider": {
"name": uid,
"authorization_flow": str(authorization_flow.pk),
},
},
)
self.assertJSONEqual(response.content.decode(), {"applied": True, "logs": []})
provider = OAuth2Provider.objects.filter(name=uid).first()
self.assertIsNotNone(provider)
app = Application.objects.filter(slug=uid).first()
self.assertIsNotNone(app)
self.assertEqual(app.provider.pk, provider.pk)
def test_create_transactional_invalid(self):
"""Test transactional Application + provider creation"""
self.client.force_login(self.user)
uid = generate_id()
response = self.client.put(
reverse("authentik_api:core-transactional-application"),
data={
"app": {
"name": uid,
"slug": uid,
},
"provider_model": "authentik_providers_oauth2.oauth2provider",
"provider": {
"name": uid,
"authorization_flow": "",
},
},
)
self.assertJSONEqual(
response.content.decode(),
{"provider": {"authorization_flow": ["This field may not be null."]}},
)

View file

@ -15,6 +15,7 @@ from authentik.core.api.propertymappings import PropertyMappingViewSet
from authentik.core.api.providers import ProviderViewSet from authentik.core.api.providers import ProviderViewSet
from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
from authentik.core.api.tokens import TokenViewSet from authentik.core.api.tokens import TokenViewSet
from authentik.core.api.transactional_applications import TransactionalApplicationView
from authentik.core.api.users import UserViewSet from authentik.core.api.users import UserViewSet
from authentik.core.views import apps from authentik.core.views import apps
from authentik.core.views.debug import AccessDeniedView from authentik.core.views.debug import AccessDeniedView
@ -70,6 +71,11 @@ urlpatterns = [
api_urlpatterns = [ api_urlpatterns = [
("core/authenticated_sessions", AuthenticatedSessionViewSet), ("core/authenticated_sessions", AuthenticatedSessionViewSet),
("core/applications", ApplicationViewSet), ("core/applications", ApplicationViewSet),
path(
"core/transactional/applications/",
TransactionalApplicationView.as_view(),
name="core-transactional-application",
),
("core/groups", GroupViewSet), ("core/groups", GroupViewSet),
("core/users", UserViewSet), ("core/users", UserViewSet),
("core/tokens", TokenViewSet), ("core/tokens", TokenViewSet),

View file

@ -181,7 +181,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
if not file: if not file:
return Response(data=import_response.initial_data, status=400) return Response(data=import_response.initial_data, status=400)
importer = Importer(file.read().decode()) importer = Importer.from_string(file.read().decode())
valid, logs = importer.validate() valid, logs = importer.validate()
import_response.initial_data["logs"] = [sanitize_dict(log) for log in logs] import_response.initial_data["logs"] = [sanitize_dict(log) for log in logs]
import_response.initial_data["success"] = valid import_response.initial_data["success"] = valid

View file

@ -73,40 +73,23 @@ QS_QUERY = "query"
def challenge_types(): def challenge_types():
"""This is a workaround for PolymorphicProxySerializer not accepting a callable for """This function returns a mapping which contains all subclasses of challenges
`serializers`. This function returns a class which is an iterator, which returns the
subclasses of Challenge, and Challenge itself.""" subclasses of Challenge, and Challenge itself."""
class Inner(dict):
"""dummy class with custom callback on .items()"""
def items(self):
mapping = {} mapping = {}
classes = all_subclasses(Challenge) for cls in all_subclasses(Challenge):
classes.remove(WithUserInfoChallenge) if cls == WithUserInfoChallenge:
for cls in classes: continue
mapping[cls().fields["component"].default] = cls mapping[cls().fields["component"].default] = cls
return mapping.items() return mapping
return Inner()
def challenge_response_types(): def challenge_response_types():
"""This is a workaround for PolymorphicProxySerializer not accepting a callable for """This function returns a mapping which contains all subclasses of challenges
`serializers`. This function returns a class which is an iterator, which returns the
subclasses of Challenge, and Challenge itself.""" subclasses of Challenge, and Challenge itself."""
class Inner(dict):
"""dummy class with custom callback on .items()"""
def items(self):
mapping = {} mapping = {}
classes = all_subclasses(ChallengeResponse) for cls in all_subclasses(ChallengeResponse):
for cls in classes:
mapping[cls(stage=None).fields["component"].default] = cls mapping[cls(stage=None).fields["component"].default] = cls
return mapping.items() return mapping
return Inner()
class InvalidStageError(SentryIgnoredException): class InvalidStageError(SentryIgnoredException):
@ -264,7 +247,7 @@ class FlowExecutorView(APIView):
responses={ responses={
200: PolymorphicProxySerializer( 200: PolymorphicProxySerializer(
component_name="ChallengeTypes", component_name="ChallengeTypes",
serializers=challenge_types(), serializers=challenge_types,
resource_type_field_name="component", resource_type_field_name="component",
), ),
}, },
@ -304,13 +287,13 @@ class FlowExecutorView(APIView):
responses={ responses={
200: PolymorphicProxySerializer( 200: PolymorphicProxySerializer(
component_name="ChallengeTypes", component_name="ChallengeTypes",
serializers=challenge_types(), serializers=challenge_types,
resource_type_field_name="component", resource_type_field_name="component",
), ),
}, },
request=PolymorphicProxySerializer( request=PolymorphicProxySerializer(
component_name="FlowChallengeResponse", component_name="FlowChallengeResponse",
serializers=challenge_response_types(), serializers=challenge_response_types,
resource_type_field_name="component", resource_type_field_name="component",
), ),
parameters=[ parameters=[

View file

@ -59,7 +59,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -95,7 +96,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -131,7 +133,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -167,7 +170,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -203,7 +207,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -239,7 +244,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -275,7 +281,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -311,7 +318,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -347,7 +355,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -383,7 +392,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -419,7 +429,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -455,7 +466,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -491,7 +503,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -527,7 +540,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -563,7 +577,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -599,7 +614,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -635,7 +651,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -671,7 +688,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -707,7 +725,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -743,7 +762,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -779,7 +799,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -815,7 +836,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -851,7 +873,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -887,7 +910,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -923,7 +947,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -959,7 +984,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -995,7 +1021,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1031,7 +1058,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1067,7 +1095,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1103,7 +1132,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1139,7 +1169,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1175,7 +1206,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1211,7 +1243,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1247,7 +1280,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1283,7 +1317,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1319,7 +1354,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1355,7 +1391,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1391,7 +1428,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1427,7 +1465,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1463,7 +1502,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1499,7 +1539,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1535,7 +1576,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1571,7 +1613,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1607,7 +1650,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1643,7 +1687,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1679,7 +1724,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1715,7 +1761,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1751,7 +1798,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1787,7 +1835,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1823,7 +1872,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1859,7 +1909,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1895,7 +1946,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1931,7 +1983,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -1967,7 +2020,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2003,7 +2057,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2039,7 +2094,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2075,7 +2131,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2111,7 +2168,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2147,7 +2205,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2183,7 +2242,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2219,7 +2279,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2255,7 +2316,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2291,7 +2353,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2327,7 +2390,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2363,7 +2427,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2399,7 +2464,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2435,7 +2501,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2471,7 +2538,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2507,7 +2575,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2543,7 +2612,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2579,7 +2649,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2615,7 +2686,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },
@ -2651,7 +2723,8 @@
"enum": [ "enum": [
"absent", "absent",
"present", "present",
"created" "created",
"must_created"
], ],
"default": "present" "default": "present"
}, },

View file

@ -4349,6 +4349,39 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' description: ''
/core/transactional/applications/:
put:
operationId: core_transactional_applications_update
description: Convert data into a blueprint, validate it and apply it
tags:
- core
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TransactionApplicationRequest'
required: true
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/TransactionApplicationResponse'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/user_consent/: /core/user_consent/:
get: get:
operationId: core_user_consent_list operationId: core_user_consent_list
@ -37543,6 +37576,22 @@ components:
description: |- description: |-
* `twilio` - Twilio * `twilio` - Twilio
* `generic` - Generic * `generic` - Generic
ProviderModelEnum:
enum:
- authentik_providers_ldap.ldapprovider
- authentik_providers_oauth2.oauth2provider
- authentik_providers_proxy.proxyprovider
- authentik_providers_radius.radiusprovider
- authentik_providers_saml.samlprovider
- authentik_providers_scim.scimprovider
type: string
description: |-
* `authentik_providers_ldap.ldapprovider` - authentik_providers_ldap.ldapprovider
* `authentik_providers_oauth2.oauth2provider` - authentik_providers_oauth2.oauth2provider
* `authentik_providers_proxy.proxyprovider` - authentik_providers_proxy.proxyprovider
* `authentik_providers_radius.radiusprovider` - authentik_providers_radius.radiusprovider
* `authentik_providers_saml.samlprovider` - authentik_providers_saml.samlprovider
* `authentik_providers_scim.scimprovider` - authentik_providers_scim.scimprovider
ProviderRequest: ProviderRequest:
type: object type: object
description: Provider Serializer description: Provider Serializer
@ -39953,6 +40002,33 @@ components:
readOnly: true readOnly: true
required: required:
- key - key
TransactionApplicationRequest:
type: object
description: Serializer for creating a provider and an application in one transaction
properties:
app:
$ref: '#/components/schemas/ApplicationRequest'
provider_model:
$ref: '#/components/schemas/ProviderModelEnum'
provider:
$ref: '#/components/schemas/modelRequest'
required:
- app
- provider
- provider_model
TransactionApplicationResponse:
type: object
description: Transactional creation response
properties:
applied:
type: boolean
logs:
type: array
items:
type: string
required:
- applied
- logs
TypeCreate: TypeCreate:
type: object type: object
description: Types of an object that can be created description: Types of an object that can be created
@ -40880,6 +40956,23 @@ components:
type: integer type: integer
required: required:
- count - count
modelRequest:
oneOf:
- $ref: '#/components/schemas/LDAPProviderRequest'
- $ref: '#/components/schemas/OAuth2ProviderRequest'
- $ref: '#/components/schemas/ProxyProviderRequest'
- $ref: '#/components/schemas/RadiusProviderRequest'
- $ref: '#/components/schemas/SAMLProviderRequest'
- $ref: '#/components/schemas/SCIMProviderRequest'
discriminator:
propertyName: provider_model
mapping:
authentik_providers_ldap.ldapprovider: '#/components/schemas/LDAPProviderRequest'
authentik_providers_oauth2.oauth2provider: '#/components/schemas/OAuth2ProviderRequest'
authentik_providers_proxy.proxyprovider: '#/components/schemas/ProxyProviderRequest'
authentik_providers_radius.radiusprovider: '#/components/schemas/RadiusProviderRequest'
authentik_providers_saml.samlprovider: '#/components/schemas/SAMLProviderRequest'
authentik_providers_scim.scimprovider: '#/components/schemas/SCIMProviderRequest'
securitySchemes: securitySchemes:
authentik: authentik:
type: apiKey type: apiKey