diff --git a/.vscode/settings.json b/.vscode/settings.json index 6858a685d..85b38a41b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,7 +24,8 @@ "!Find sequence", "!KeyOf scalar", "!Context scalar", - "!Format sequence" + "!Format sequence", + "!Condition sequence" ], "typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.importModuleSpecifierEnding": "index", diff --git a/authentik/blueprints/tests/fixtures/conditions_fulfilled.yaml b/authentik/blueprints/tests/fixtures/conditions_fulfilled.yaml new file mode 100644 index 000000000..659bd84b8 --- /dev/null +++ b/authentik/blueprints/tests/fixtures/conditions_fulfilled.yaml @@ -0,0 +1,21 @@ +version: 1 +entries: +- identifiers: + name: "%(id1)s" + slug: "%(id1)s" + model: authentik_flows.flow + conditions: + - true + attrs: + designation: stage_configuration + title: foo +- identifiers: + name: "%(id2)s" + slug: "%(id2)s" + model: authentik_flows.flow + conditions: + - true + - true + attrs: + designation: stage_configuration + title: foo diff --git a/authentik/blueprints/tests/fixtures/conditions_not_fulfilled.yaml b/authentik/blueprints/tests/fixtures/conditions_not_fulfilled.yaml new file mode 100644 index 000000000..0e4b9abdb --- /dev/null +++ b/authentik/blueprints/tests/fixtures/conditions_not_fulfilled.yaml @@ -0,0 +1,21 @@ +version: 1 +entries: +- identifiers: + name: "%(id1)s" + slug: "%(id1)s" + model: authentik_flows.flow + conditions: + - false + attrs: + designation: stage_configuration + title: foo +- identifiers: + name: "%(id2)s" + slug: "%(id2)s" + model: authentik_flows.flow + conditions: + - true + - false + attrs: + designation: stage_configuration + title: foo diff --git a/authentik/blueprints/tests/fixtures/tags.yaml b/authentik/blueprints/tests/fixtures/tags.yaml index d8b5a673d..b91132df5 100644 --- a/authentik/blueprints/tests/fixtures/tags.yaml +++ b/authentik/blueprints/tests/fixtures/tags.yaml @@ -14,6 +14,17 @@ entries: attributes: policy_pk1: !Format ["%s-%s", !Find [authentik_policies_expression.expressionpolicy, [!Context policy_property, !Context policy_property_value], [expression, return True]], suffix] policy_pk2: !Format ["%s-%s", !KeyOf policy, suffix] + boolAnd: !Condition [AND, !Context foo, !Format ["%s", "a_string"], 1] + boolNand: !Condition [NAND, !Context foo, !Format ["%s", "a_string"], 1] + boolOr: !Condition [OR, !Context foo, !Format ["%s", "a_string"], null] + boolNor: !Condition [NOR, !Context foo, !Format ["%s", "a_string"], null] + boolXor: !Condition [XOR, !Context foo, !Format ["%s", "a_string"], 1] + boolXnor: !Condition [XNOR, !Context foo, !Format ["%s", "a_string"], 1] + boolComplex: !Condition [XNOR, !Condition [AND, !Context non_existing], !Condition [NOR, a string], !Condition [XOR, null]] identifiers: name: test + conditions: + - !Condition [AND, true, true, text] + - true + - text model: authentik_core.group diff --git a/authentik/blueprints/tests/test_v1.py b/authentik/blueprints/tests/test_v1.py index 512660281..666c6de43 100644 --- a/authentik/blueprints/tests/test_v1.py +++ b/authentik/blueprints/tests/test_v1.py @@ -139,9 +139,16 @@ class TestBlueprintsV1(TransactionTestCase): self.assertTrue(policy) self.assertTrue( Group.objects.filter( - attributes__contains={ + attributes={ "policy_pk1": str(policy.pk) + "-suffix", "policy_pk2": str(policy.pk) + "-suffix", + "boolAnd": True, + "boolNand": False, + "boolOr": True, + "boolNor": False, + "boolXor": True, + "boolXnor": False, + "boolComplex": True, } ) ) diff --git a/authentik/blueprints/tests/test_v1_conditions.py b/authentik/blueprints/tests/test_v1_conditions.py new file mode 100644 index 000000000..02d657303 --- /dev/null +++ b/authentik/blueprints/tests/test_v1_conditions.py @@ -0,0 +1,43 @@ +"""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 TestBlueprintsV1Conditions(TransactionTestCase): + """Test Blueprints conditions attribute""" + + def test_conditions_fulfilled(self): + """Test conditions fulfilled""" + flow_slug1 = generate_id() + flow_slug2 = generate_id() + import_yaml = load_yaml_fixture( + "fixtures/conditions_fulfilled.yaml", id1=flow_slug1, id2=flow_slug2 + ) + + importer = Importer(import_yaml) + self.assertTrue(importer.validate()[0]) + self.assertTrue(importer.apply()) + # Ensure objects exist + flow: Flow = Flow.objects.filter(slug=flow_slug1).first() + self.assertEqual(flow.slug, flow_slug1) + flow: Flow = Flow.objects.filter(slug=flow_slug2).first() + self.assertEqual(flow.slug, flow_slug2) + + def test_conditions_not_fulfilled(self): + """Test conditions not fulfilled""" + flow_slug1 = generate_id() + flow_slug2 = generate_id() + import_yaml = load_yaml_fixture( + "fixtures/conditions_not_fulfilled.yaml", id1=flow_slug1, id2=flow_slug2 + ) + + importer = Importer(import_yaml) + self.assertTrue(importer.validate()[0]) + self.assertTrue(importer.apply()) + # Ensure objects do not exist + self.assertFalse(Flow.objects.filter(slug=flow_slug1)) + self.assertFalse(Flow.objects.filter(slug=flow_slug2)) diff --git a/authentik/blueprints/v1/common.py b/authentik/blueprints/v1/common.py index 668f5091a..9e725a1d9 100644 --- a/authentik/blueprints/v1/common.py +++ b/authentik/blueprints/v1/common.py @@ -2,7 +2,9 @@ from collections import OrderedDict from dataclasses import asdict, dataclass, field, is_dataclass from enum import Enum -from typing import Any, Optional +from functools import reduce +from operator import ixor +from typing import Any, Literal, Optional from uuid import UUID from django.apps import apps @@ -55,6 +57,7 @@ class BlueprintEntry: model: str state: BlueprintEntryDesiredState = field(default=BlueprintEntryDesiredState.PRESENT) + conditions: list[Any] = field(default_factory=list) identifiers: dict[str, Any] = field(default_factory=dict) attrs: Optional[dict[str, Any]] = field(default_factory=dict) @@ -99,6 +102,10 @@ class BlueprintEntry: """Get attributes of this entry, with all yaml tags resolved""" return self.tag_resolver(self.identifiers, blueprint) + def check_all_conditions_match(self, blueprint: "Blueprint") -> bool: + """Check all conditions of this entry match (evaluate to True)""" + return all(self.tag_resolver(self.conditions, blueprint)) + @dataclass class BlueprintMetadata: @@ -241,6 +248,49 @@ class Find(YAMLTag): return None +class Condition(YAMLTag): + """Convert all values to a single boolean""" + + mode: Literal["AND", "NAND", "OR", "NOR", "XOR", "XNOR"] + args: list[Any] + + _COMPARATORS = { + # Using all and any here instead of from operator import iand, ior + # to improve performance + "AND": all, + "NAND": lambda args: not all(args), + "OR": any, + "NOR": lambda args: not any(args), + "XOR": lambda args: reduce(ixor, args) if len(args) > 1 else args[0], + "XNOR": lambda args: not (reduce(ixor, args) if len(args) > 1 else args[0]), + } + + # pylint: disable=unused-argument + def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None: + super().__init__() + self.mode = node.value[0].value + self.args = [] + for raw_node in node.value[1:]: + self.args.append(loader.construct_object(raw_node)) + + def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: + args = [] + for arg in self.args: + if isinstance(arg, YAMLTag): + args.append(arg.resolve(entry, blueprint)) + else: + args.append(arg) + + if not args: + raise EntryInvalidError("At least one value is required after mode selection.") + + try: + comparator = self._COMPARATORS[self.mode.upper()] + return comparator(tuple(bool(x) for x in args)) + except (TypeError, KeyError) as exc: + raise EntryInvalidError(exc) + + class BlueprintDumper(SafeDumper): """Dump dataclasses to yaml""" @@ -281,6 +331,7 @@ class BlueprintLoader(SafeLoader): self.add_constructor("!Find", Find) self.add_constructor("!Context", Context) self.add_constructor("!Format", Format) + self.add_constructor("!Condition", Condition) class EntryInvalidError(SentryIgnoredException): diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py index da65270a9..705e103de 100644 --- a/authentik/blueprints/v1/importer.py +++ b/authentik/blueprints/v1/importer.py @@ -144,6 +144,10 @@ class Importer: # pylint: disable-msg=too-many-locals def _validate_single(self, entry: BlueprintEntry) -> Optional[BaseSerializer]: """Validate a single entry""" + if not entry.check_all_conditions_match(self.__import): + self.logger.debug("One or more conditions of this entry are not fulfilled, skipping") + return None + model_app_label, model_name = entry.model.split(".") model: type[SerializerModel] = registry.get_model(model_app_label, model_name) # Don't use isinstance since we don't want to check for inheritance diff --git a/website/developer-docs/blueprints/v1/structure.md b/website/developer-docs/blueprints/v1/structure.md index 1bcfda383..78ebceb89 100644 --- a/website/developer-docs/blueprints/v1/structure.md +++ b/website/developer-docs/blueprints/v1/structure.md @@ -25,6 +25,18 @@ entries: # the object is created (and create it with the values given here), and "absent" will # delete the object state: present + # An optional list of boolean-like conditions. If all conditions match (or + # no condiitons are provided) the entry will be evaluated and acted upon + # as normal. Otherwise, the entry is skipped as if not defined at all. + # Each condition will be evaluated in Python to its boolean representation + # bool(). Furthermore, complex conditions can be built using + # a special !Condition tag. See the documentattion for custom tags for more + # information. + conditions: + - true + - text + - 2 + - !Condition [AND, ...] # See custom tags section # Key:value filters to uniquely identify this object (required) identifiers: slug: initial-setup diff --git a/website/developer-docs/blueprints/v1/tags.md b/website/developer-docs/blueprints/v1/tags.md index 2970a3316..9a72cab7b 100644 --- a/website/developer-docs/blueprints/v1/tags.md +++ b/website/developer-docs/blueprints/v1/tags.md @@ -38,3 +38,28 @@ Find values from the context. Can optionally be called with a default like `!Con Example: `name: !Format [my-policy-%s, !Context instance_name]` Format a string using python's % formatting. First argument is the format string, any remaining arguments are used for formatting. + +#### `!Condition` + +Minimal example: + +`required: !Condition [OR, true]` + +Full example: + +``` +required: !Condition [ + AND, # Valid modes are: AND, NAND, OR, NOR, XOR, XNOR + !Context instance_name, + !Find [authentik_flows.flow, [slug, default-password-change], + "My string", + 123 +] +``` + +Converts one or more arguments to their boolean representations, then merges all representations together. +Requires at least one argument after the mode selection. + +If only a single argument is provided, its boolean representation will be returned for all normal modes and its negated boolean representation will be returned for all negated modes. + +Normally, it should be used to define complex conditions for the `conditions` attribute of a blueprint entry (see [the blueprint file structure](./structure.md)). However, this is essentially just a boolean evaluator so it can be used everywhere a boolean representation is required.