blueprints: add desired state attribute to objects (#4061)

* add state attribute to delete objects

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add tests, move yaml from block to files

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add state to docs

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* only try to format

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens L 2022-11-22 14:27:20 +01:00 committed by GitHub
parent 14cd52686d
commit ab3d47c437
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 220 additions and 41 deletions

View file

@ -20,7 +20,12 @@
"todo-tree.tree.showCountsInTree": true, "todo-tree.tree.showCountsInTree": true,
"todo-tree.tree.showBadges": true, "todo-tree.tree.showBadges": true,
"python.formatting.provider": "black", "python.formatting.provider": "black",
"yaml.customTags": ["!Find sequence", "!KeyOf scalar"], "yaml.customTags": [
"!Find sequence",
"!KeyOf scalar",
"!Context scalar",
"!Format sequence"
],
"typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifierEnding": "index", "typescript.preferences.importModuleSpecifierEnding": "index",
"typescript.tsdk": "./web/node_modules/typescript/lib", "typescript.tsdk": "./web/node_modules/typescript/lib",

View file

@ -53,6 +53,15 @@
"id": { "id": {
"type": "string" "type": "string"
}, },
"state": {
"type": "string",
"enum": [
"absent",
"present",
"created"
],
"default": "present"
},
"attrs": { "attrs": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -7,7 +7,6 @@ from django.apps import apps
from authentik.blueprints.apps import ManagedAppConfig from authentik.blueprints.apps import ManagedAppConfig
from authentik.blueprints.models import BlueprintInstance from authentik.blueprints.models import BlueprintInstance
from authentik.lib.config import CONFIG
def apply_blueprint(*files: str): def apply_blueprint(*files: str):
@ -46,3 +45,13 @@ def reconcile_app(app_name: str):
return wrapper return wrapper
return wrapper_outer return wrapper_outer
def load_yaml_fixture(path: str, **kwargs) -> str:
"""Load yaml fixture, optionally formatting it with kwargs"""
with open(Path(__file__).resolve().parent / Path(path), "r+", encoding="utf-8") as _fixture:
fixture = _fixture.read()
try:
return fixture % kwargs
except TypeError:
return fixture

View file

@ -0,0 +1,7 @@
version: 1
entries:
- identifiers:
name: "%(id)s"
slug: "%(id)s"
model: authentik_flows.flow
state: absent

View file

@ -0,0 +1,10 @@
version: 1
entries:
- identifiers:
name: "%(id)s"
slug: "%(id)s"
model: authentik_flows.flow
state: created
attrs:
designation: stage_configuration
title: foo

View file

@ -0,0 +1,10 @@
version: 1
entries:
- identifiers:
name: "%(id)s"
slug: "%(id)s"
model: authentik_flows.flow
state: present
attrs:
designation: stage_configuration
title: foo

View file

@ -0,0 +1,12 @@
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

View file

@ -0,0 +1,10 @@
version: 1
context:
foo: bar
entries:
- attrs:
expression: return True
identifiers:
name: !Format [foo-%s-%s, !Context foo, !Context bar]
id: default-source-enrollment-if-username
model: authentik_policies_expression.expressionpolicy

View file

@ -1,6 +1,7 @@
"""Test blueprints v1""" """Test blueprints v1"""
from django.test import TransactionTestCase from django.test import TransactionTestCase
from authentik.blueprints.tests import load_yaml_fixture
from authentik.blueprints.v1.exporter import FlowExporter from authentik.blueprints.v1.exporter import FlowExporter
from authentik.blueprints.v1.importer import Importer, transaction_rollback from authentik.blueprints.v1.importer import Importer, transaction_rollback
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
@ -10,32 +11,6 @@ from authentik.policies.models import PolicyBinding
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
from authentik.stages.user_login.models import UserLoginStage from authentik.stages.user_login.models import UserLoginStage
STATIC_PROMPT_EXPORT = """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
"""
YAML_TAG_TESTS = """version: 1
context:
foo: bar
entries:
- attrs:
expression: return True
identifiers:
name: !Format [foo-%s-%s, !Context foo, !Context bar]
id: default-source-enrollment-if-username
model: authentik_policies_expression.expressionpolicy
"""
class TestBlueprintsV1(TransactionTestCase): class TestBlueprintsV1(TransactionTestCase):
"""Test Blueprints""" """Test Blueprints"""
@ -85,14 +60,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(STATIC_PROMPT_EXPORT) importer = Importer(load_yaml_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(STATIC_PROMPT_EXPORT) importer = Importer(load_yaml_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)
@ -100,7 +75,7 @@ class TestBlueprintsV1(TransactionTestCase):
def test_import_yaml_tags(self): def test_import_yaml_tags(self):
"""Test some yaml tags""" """Test some yaml tags"""
ExpressionPolicy.objects.filter(name="foo-foo-bar").delete() ExpressionPolicy.objects.filter(name="foo-foo-bar").delete()
importer = Importer(YAML_TAG_TESTS, {"bar": "baz"}) importer = Importer(load_yaml_fixture("fixtures/tags.yaml"), {"bar": "baz"})
self.assertTrue(importer.validate()[0]) self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply()) self.assertTrue(importer.apply())
self.assertTrue(ExpressionPolicy.objects.filter(name="foo-foo-bar")) self.assertTrue(ExpressionPolicy.objects.filter(name="foo-foo-bar"))

View file

@ -0,0 +1,82 @@
"""Test blueprints v1"""
from django.test import TransactionTestCase
from authentik.blueprints.tests import load_yaml_fixture
from authentik.blueprints.v1.importer import Importer
from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
class TestBlueprintsV1State(TransactionTestCase):
"""Test Blueprints state attribute"""
def test_state_present(self):
"""Test state present"""
flow_slug = generate_id()
import_yaml = load_yaml_fixture("fixtures/state_present.yaml", id=flow_slug)
importer = Importer(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
# Ensure object exists
flow: Flow = Flow.objects.filter(slug=flow_slug).first()
self.assertEqual(flow.slug, flow_slug)
# Update object
flow.title = "bar"
flow.save()
flow.refresh_from_db()
self.assertEqual(flow.title, "bar")
# Ensure importer updates it
importer = Importer(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
flow: Flow = Flow.objects.filter(slug=flow_slug).first()
self.assertEqual(flow.title, "foo")
def test_state_created(self):
"""Test state created"""
flow_slug = generate_id()
import_yaml = load_yaml_fixture("fixtures/state_created.yaml", id=flow_slug)
importer = Importer(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
# Ensure object exists
flow: Flow = Flow.objects.filter(slug=flow_slug).first()
self.assertEqual(flow.slug, flow_slug)
# Update object
flow.title = "bar"
flow.save()
flow.refresh_from_db()
self.assertEqual(flow.title, "bar")
# Ensure importer doesn't update it
importer = Importer(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
flow: Flow = Flow.objects.filter(slug=flow_slug).first()
self.assertEqual(flow.title, "bar")
def test_state_absent(self):
"""Test state absent"""
flow_slug = generate_id()
import_yaml = load_yaml_fixture("fixtures/state_created.yaml", id=flow_slug)
importer = Importer(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
# Ensure object exists
flow: Flow = Flow.objects.filter(slug=flow_slug).first()
self.assertEqual(flow.slug, flow_slug)
import_yaml = load_yaml_fixture("fixtures/state_absent.yaml", id=flow_slug)
importer = Importer(import_yaml)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
flow: Flow = Flow.objects.filter(slug=flow_slug).first()
self.assertIsNone(flow)

View file

@ -41,11 +41,20 @@ class BlueprintEntryState:
instance: Optional[Model] = None instance: Optional[Model] = None
class BlueprintEntryDesiredState(Enum):
"""State an entry should be reconciled to"""
ABSENT = "absent"
PRESENT = "present"
CREATED = "created"
@dataclass @dataclass
class BlueprintEntry: class BlueprintEntry:
"""Single entry of a blueprint""" """Single entry of a blueprint"""
model: str model: str
state: BlueprintEntryDesiredState = field(default=BlueprintEntryDesiredState.PRESENT)
identifiers: dict[str, Any] = field(default_factory=dict) identifiers: dict[str, Any] = field(default_factory=dict)
attrs: Optional[dict[str, Any]] = field(default_factory=dict) attrs: Optional[dict[str, Any]] = field(default_factory=dict)
@ -227,8 +236,15 @@ class BlueprintDumper(SafeDumper):
self.add_representer(UUID, lambda self, data: self.represent_str(str(data))) self.add_representer(UUID, lambda self, data: self.represent_str(str(data)))
self.add_representer(OrderedDict, lambda self, data: self.represent_dict(dict(data))) self.add_representer(OrderedDict, lambda self, data: self.represent_dict(dict(data)))
self.add_representer(Enum, lambda self, data: self.represent_str(data.value)) self.add_representer(Enum, lambda self, data: self.represent_str(data.value))
self.add_representer(
BlueprintEntryDesiredState, lambda self, data: self.represent_str(data.value)
)
self.add_representer(None, lambda self, data: self.represent_str(str(data))) self.add_representer(None, lambda self, data: self.represent_str(str(data)))
def ignore_aliases(self, data):
"""Don't use any YAML anchors"""
return True
def represent(self, data) -> None: def represent(self, data) -> None:
if is_dataclass(data): if is_dataclass(data):

View file

@ -3,6 +3,7 @@ from contextlib import contextmanager
from copy import deepcopy from copy import deepcopy
from typing import Any, Optional from typing import Any, Optional
from dacite.config import Config
from dacite.core import from_dict 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
@ -20,6 +21,7 @@ from yaml import load
from authentik.blueprints.v1.common import ( from authentik.blueprints.v1.common import (
Blueprint, Blueprint,
BlueprintEntry, BlueprintEntry,
BlueprintEntryDesiredState,
BlueprintEntryState, BlueprintEntryState,
BlueprintLoader, BlueprintLoader,
EntryInvalidError, EntryInvalidError,
@ -82,7 +84,9 @@ class Importer:
self.logger = get_logger() self.logger = get_logger()
import_dict = load(yaml_input, BlueprintLoader) import_dict = load(yaml_input, BlueprintLoader)
try: try:
self.__import = from_dict(Blueprint, import_dict) self.__import = from_dict(
Blueprint, import_dict, config=Config(cast=[BlueprintEntryDesiredState])
)
except DaciteError as exc: except DaciteError as exc:
raise EntryInvalidError from exc raise EntryInvalidError from exc
ctx = {} ctx = {}
@ -135,7 +139,7 @@ class Importer:
sub_query &= Q(**{identifier: value}) sub_query &= Q(**{identifier: value})
return main_query | sub_query return main_query | sub_query
def _validate_single(self, entry: BlueprintEntry) -> BaseSerializer: def _validate_single(self, entry: BlueprintEntry) -> Optional[BaseSerializer]:
"""Validate a single entry""" """Validate a single entry"""
model_app_label, model_name = entry.model.split(".") model_app_label, model_name = entry.model.split(".")
model: type[SerializerModel] = registry.get_model(model_app_label, model_name) model: type[SerializerModel] = registry.get_model(model_app_label, model_name)
@ -168,8 +172,11 @@ class Importer:
existing_models = model.objects.filter(self.__query_from_identifier(updated_identifiers)) existing_models = model.objects.filter(self.__query_from_identifier(updated_identifiers))
serializer_kwargs = {} serializer_kwargs = {}
if not isinstance(model(), BaseMetaModel) and existing_models.exists(): model_instance = existing_models.first()
model_instance = existing_models.first() if not isinstance(model(), BaseMetaModel) and model_instance:
if entry.state == BlueprintEntryDesiredState.CREATED:
self.logger.debug("instance exists, skipping")
return None
self.logger.debug( self.logger.debug(
"initialise serializer with instance", "initialise serializer with instance",
model=model, model=model,
@ -234,12 +241,25 @@ class Importer:
except EntryInvalidError as exc: except EntryInvalidError as exc:
self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc) self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc)
return False return False
if not serializer:
continue
model = serializer.save() if entry.state in [
if "pk" in entry.identifiers: BlueprintEntryDesiredState.PRESENT,
self.__pk_map[entry.identifiers["pk"]] = model.pk BlueprintEntryDesiredState.CREATED,
entry._state = BlueprintEntryState(model) ]:
self.logger.debug("updated model", model=model) model = serializer.save()
if "pk" in entry.identifiers:
self.__pk_map[entry.identifiers["pk"]] = model.pk
entry._state = BlueprintEntryState(model)
self.logger.debug("updated model", model=model)
elif entry.state == BlueprintEntryDesiredState.ABSENT:
instance: Optional[Model] = serializer.instance
if instance:
instance.delete()
self.logger.debug("deleted model", mode=instance)
continue
self.logger.debug("entry to delete with no instance, skipping")
return True return True
def validate(self) -> tuple[bool, list[EventDict]]: def validate(self) -> tuple[bool, list[EventDict]]:

View file

@ -121,6 +121,15 @@
"id": { "id": {
"type": "string" "type": "string"
}, },
"state": {
"type": "string",
"enum": [
"absent",
"present",
"created"
],
"default": "present"
},
"attrs": { "attrs": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -24,4 +24,4 @@ This export can be triggered via the API or the Web UI by clicking the download
## Cleaning up ## Cleaning up
Exports from either method will contain a (potentially) long list of objects, all with hardcoded primary keys and now ability for templating/instantiation. This is because currently, authentik does not check which primary keys are used where. It is assumed that for most exports, there'll be some manual changes done regardless, to filter out unwanted objects, adjust properties, etc. Exports from either method will contain a (potentially) long list of objects, all with hardcoded primary keys and no ability for templating/instantiation. This is because currently, authentik does not check which primary keys are used where. It is assumed that for most exports, there'll be some manual changes done regardless, to filter out unwanted objects, adjust properties, etc.

View file

@ -20,6 +20,11 @@ context:
entries: entries:
- # Model in app.model notation, possibilities are listed in the schema (required) - # Model in app.model notation, possibilities are listed in the schema (required)
model: authentik_flows.flow model: authentik_flows.flow
# The state this object should be in (optional, can be "present", "created" or "absent")
# Present will keep the object in sync with its definition here, created will only ensure
# the object is created (and create it with the values given here), and "absent" will
# delete the object
state: present
# Key:value filters to uniquely identify this object (required) # Key:value filters to uniquely identify this object (required)
identifiers: identifiers:
slug: initial-setup slug: initial-setup