Repository: goauthentik/authentik

Issue:
Blueprint application does not remove flow stages
When applying an updated blueprint to an existing flow any stages that have been removed from the blueprint YAML are not removed from the flow.

To reproduce:
1. Start with a clean authentik 2022.10.1 release
2. Complete initial setup, login, etc.
3. (Optional, did it just to confirm it is not due to a dependency on something else) Delete all flows, tenants, policies, stages, prompts. etc.
4. Apply just the [default authentication flow](https://github.com/goauthentik/authentik/blob/3306003f0e0ac350eaecf3ba3584fcf6c6f4a4d1/blueprints/default/10-flow-default-authentication-flow.yaml) from the default blueprint folder.
5. Confirm the blueprint has been applied
6. Modify the default blueprint by commenting out the MFA stage and its corresponding stage bind. Optionally, modify the flow's title and name strings to easily confirm it has been re-applied
7. Re-apply the blueprint
8.  Confirm the blueprint has been re-applied and it still has the same number of stages (4).
9. Delete the flow
10. Re-apply the blueprint
11. Confirm the blueprint has been re-applied but the flow has 1 stage less (3).




Discussion:

Resolving patch:
diff --git a/authentik/blueprints/v1/common.py b/authentik/blueprints/v1/common.py
--- a/authentik/blueprints/v1/common.py
+++ b/authentik/blueprints/v1/common.py
@@ -41,11 +41,20 @@ class BlueprintEntryState:
     instance: Optional[Model] = None
 
 
+class BlueprintEntryDesiredState(Enum):
+    """State an entry should be reconciled to"""
+
+    ABSENT = "absent"
+    PRESENT = "present"
+    CREATED = "created"
+
+
 @dataclass
 class BlueprintEntry:
     """Single entry of a blueprint"""
 
     model: str
+    state: BlueprintEntryDesiredState = field(default=BlueprintEntryDesiredState.PRESENT)
     identifiers: dict[str, Any] = field(default_factory=dict)
     attrs: Optional[dict[str, Any]] = field(default_factory=dict)
 
@@ -227,8 +236,15 @@ def __init__(self, *args, **kwargs):
         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(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)))
 
+    def ignore_aliases(self, data):
+        """Don't use any YAML anchors"""
+        return True
+
     def represent(self, data) -> None:
         if is_dataclass(data):
 
diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py
--- a/authentik/blueprints/v1/importer.py
+++ b/authentik/blueprints/v1/importer.py
@@ -3,6 +3,7 @@
 from copy import deepcopy
 from typing import Any, Optional
 
+from dacite.config import Config
 from dacite.core import from_dict
 from dacite.exceptions import DaciteError
 from deepmerge import always_merger
@@ -20,6 +21,7 @@
 from authentik.blueprints.v1.common import (
     Blueprint,
     BlueprintEntry,
+    BlueprintEntryDesiredState,
     BlueprintEntryState,
     BlueprintLoader,
     EntryInvalidError,
@@ -82,7 +84,9 @@ def __init__(self, yaml_input: str, context: Optional[dict] = None):
         self.logger = get_logger()
         import_dict = load(yaml_input, BlueprintLoader)
         try:
-            self.__import = from_dict(Blueprint, import_dict)
+            self.__import = from_dict(
+                Blueprint, import_dict, config=Config(cast=[BlueprintEntryDesiredState])
+            )
         except DaciteError as exc:
             raise EntryInvalidError from exc
         ctx = {}
@@ -135,7 +139,7 @@ def __query_from_identifier(self, attrs: dict[str, Any]) -> Q:
             sub_query &= Q(**{identifier: value})
         return main_query | sub_query
 
-    def _validate_single(self, entry: BlueprintEntry) -> BaseSerializer:
+    def _validate_single(self, entry: BlueprintEntry) -> Optional[BaseSerializer]:
         """Validate a single entry"""
         model_app_label, model_name = entry.model.split(".")
         model: type[SerializerModel] = registry.get_model(model_app_label, model_name)
@@ -168,8 +172,11 @@ def _validate_single(self, entry: BlueprintEntry) -> BaseSerializer:
         existing_models = model.objects.filter(self.__query_from_identifier(updated_identifiers))
 
         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(
                 "initialise serializer with instance",
                 model=model,
@@ -234,12 +241,25 @@ def _apply_models(self) -> bool:
             except EntryInvalidError as exc:
                 self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc)
                 return False
+            if not serializer:
+                continue
 
-            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)
+            if entry.state in [
+                BlueprintEntryDesiredState.PRESENT,
+                BlueprintEntryDesiredState.CREATED,
+            ]:
+                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
 
     def validate(self) -> tuple[bool, list[EventDict]]:


Test patch:
diff --git a/authentik/blueprints/tests/__init__.py b/authentik/blueprints/tests/__init__.py
--- a/authentik/blueprints/tests/__init__.py
+++ b/authentik/blueprints/tests/__init__.py
@@ -7,7 +7,6 @@
 
 from authentik.blueprints.apps import ManagedAppConfig
 from authentik.blueprints.models import BlueprintInstance
-from authentik.lib.config import CONFIG
 
 
 def apply_blueprint(*files: str):
@@ -46,3 +45,13 @@ def wrapper(*args, **kwargs):
         return wrapper
 
     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
diff --git a/authentik/blueprints/tests/fixtures/state_absent.yaml b/authentik/blueprints/tests/fixtures/state_absent.yaml
new file mode 100644
--- /dev/null
+++ b/authentik/blueprints/tests/fixtures/state_absent.yaml
@@ -0,0 +1,7 @@
+version: 1
+entries:
+- identifiers:
+    name: "%(id)s"
+    slug: "%(id)s"
+  model: authentik_flows.flow
+  state: absent
diff --git a/authentik/blueprints/tests/fixtures/state_created.yaml b/authentik/blueprints/tests/fixtures/state_created.yaml
new file mode 100644
--- /dev/null
+++ b/authentik/blueprints/tests/fixtures/state_created.yaml
@@ -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
diff --git a/authentik/blueprints/tests/fixtures/state_present.yaml b/authentik/blueprints/tests/fixtures/state_present.yaml
new file mode 100644
--- /dev/null
+++ b/authentik/blueprints/tests/fixtures/state_present.yaml
@@ -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
diff --git a/authentik/blueprints/tests/fixtures/static_prompt_export.yaml b/authentik/blueprints/tests/fixtures/static_prompt_export.yaml
new file mode 100644
--- /dev/null
+++ b/authentik/blueprints/tests/fixtures/static_prompt_export.yaml
@@ -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
diff --git a/authentik/blueprints/tests/fixtures/tags.yaml b/authentik/blueprints/tests/fixtures/tags.yaml
new file mode 100644
--- /dev/null
+++ b/authentik/blueprints/tests/fixtures/tags.yaml
@@ -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
diff --git a/authentik/blueprints/tests/test_v1.py b/authentik/blueprints/tests/test_v1.py
--- a/authentik/blueprints/tests/test_v1.py
+++ b/authentik/blueprints/tests/test_v1.py
@@ -1,6 +1,7 @@
 """Test blueprints v1"""
 from django.test import TransactionTestCase
 
+from authentik.blueprints.tests import load_yaml_fixture
 from authentik.blueprints.v1.exporter import FlowExporter
 from authentik.blueprints.v1.importer import Importer, transaction_rollback
 from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
@@ -10,32 +11,6 @@
 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
-"""
-
-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):
     """Test Blueprints"""
@@ -85,14 +60,14 @@ def test_export_validate_import_re_import(self):
         """Test export and import it twice"""
         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.apply())
 
         count_before = Prompt.objects.filter(field_key="username").count()
         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.assertEqual(Prompt.objects.filter(field_key="username").count(), count_before)
@@ -100,7 +75,7 @@ def test_export_validate_import_re_import(self):
     def test_import_yaml_tags(self):
         """Test some yaml tags"""
         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.apply())
         self.assertTrue(ExpressionPolicy.objects.filter(name="foo-foo-bar"))
diff --git a/authentik/blueprints/tests/test_v1_state.py b/authentik/blueprints/tests/test_v1_state.py
new file mode 100644
--- /dev/null
+++ b/authentik/blueprints/tests/test_v1_state.py
@@ -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)