blueprints: Added conditional entry application (#4167)
* blueprints: Added !AsBool tag * Renamed AsBool tag to Condition * Added conditions attributed to BlueprintEntry * Added docs for the conditions attribute of a blueprint entry * Website linting fix * add new tag to vscode settings Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> Co-authored-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
e9f5d7aefe
commit
7f662ac2f3
|
@ -24,7 +24,8 @@
|
||||||
"!Find sequence",
|
"!Find sequence",
|
||||||
"!KeyOf scalar",
|
"!KeyOf scalar",
|
||||||
"!Context scalar",
|
"!Context scalar",
|
||||||
"!Format sequence"
|
"!Format sequence",
|
||||||
|
"!Condition sequence"
|
||||||
],
|
],
|
||||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
"typescript.preferences.importModuleSpecifierEnding": "index",
|
"typescript.preferences.importModuleSpecifierEnding": "index",
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -14,6 +14,17 @@ entries:
|
||||||
attributes:
|
attributes:
|
||||||
policy_pk1: !Format ["%s-%s", !Find [authentik_policies_expression.expressionpolicy, [!Context policy_property, !Context policy_property_value], [expression, return True]], suffix]
|
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]
|
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:
|
identifiers:
|
||||||
name: test
|
name: test
|
||||||
|
conditions:
|
||||||
|
- !Condition [AND, true, true, text]
|
||||||
|
- true
|
||||||
|
- text
|
||||||
model: authentik_core.group
|
model: authentik_core.group
|
||||||
|
|
|
@ -139,9 +139,16 @@ class TestBlueprintsV1(TransactionTestCase):
|
||||||
self.assertTrue(policy)
|
self.assertTrue(policy)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
Group.objects.filter(
|
Group.objects.filter(
|
||||||
attributes__contains={
|
attributes={
|
||||||
"policy_pk1": str(policy.pk) + "-suffix",
|
"policy_pk1": str(policy.pk) + "-suffix",
|
||||||
"policy_pk2": str(policy.pk) + "-suffix",
|
"policy_pk2": str(policy.pk) + "-suffix",
|
||||||
|
"boolAnd": True,
|
||||||
|
"boolNand": False,
|
||||||
|
"boolOr": True,
|
||||||
|
"boolNor": False,
|
||||||
|
"boolXor": True,
|
||||||
|
"boolXnor": False,
|
||||||
|
"boolComplex": True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
@ -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))
|
|
@ -2,7 +2,9 @@
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from dataclasses import asdict, dataclass, field, is_dataclass
|
from dataclasses import asdict, dataclass, field, is_dataclass
|
||||||
from enum import Enum
|
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 uuid import UUID
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
@ -55,6 +57,7 @@ class BlueprintEntry:
|
||||||
|
|
||||||
model: str
|
model: str
|
||||||
state: BlueprintEntryDesiredState = field(default=BlueprintEntryDesiredState.PRESENT)
|
state: BlueprintEntryDesiredState = field(default=BlueprintEntryDesiredState.PRESENT)
|
||||||
|
conditions: list[Any] = field(default_factory=list)
|
||||||
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)
|
||||||
|
|
||||||
|
@ -99,6 +102,10 @@ class BlueprintEntry:
|
||||||
"""Get attributes of this entry, with all yaml tags resolved"""
|
"""Get attributes of this entry, with all yaml tags resolved"""
|
||||||
return self.tag_resolver(self.identifiers, blueprint)
|
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
|
@dataclass
|
||||||
class BlueprintMetadata:
|
class BlueprintMetadata:
|
||||||
|
@ -241,6 +248,49 @@ class Find(YAMLTag):
|
||||||
return None
|
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):
|
class BlueprintDumper(SafeDumper):
|
||||||
"""Dump dataclasses to yaml"""
|
"""Dump dataclasses to yaml"""
|
||||||
|
|
||||||
|
@ -281,6 +331,7 @@ class BlueprintLoader(SafeLoader):
|
||||||
self.add_constructor("!Find", Find)
|
self.add_constructor("!Find", Find)
|
||||||
self.add_constructor("!Context", Context)
|
self.add_constructor("!Context", Context)
|
||||||
self.add_constructor("!Format", Format)
|
self.add_constructor("!Format", Format)
|
||||||
|
self.add_constructor("!Condition", Condition)
|
||||||
|
|
||||||
|
|
||||||
class EntryInvalidError(SentryIgnoredException):
|
class EntryInvalidError(SentryIgnoredException):
|
||||||
|
|
|
@ -144,6 +144,10 @@ 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):
|
||||||
|
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_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)
|
||||||
# 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
|
||||||
|
|
|
@ -25,6 +25,18 @@ entries:
|
||||||
# the object is created (and create it with the values given here), and "absent" will
|
# the object is created (and create it with the values given here), and "absent" will
|
||||||
# delete the object
|
# delete the object
|
||||||
state: present
|
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(<condition>). 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)
|
# Key:value filters to uniquely identify this object (required)
|
||||||
identifiers:
|
identifiers:
|
||||||
slug: initial-setup
|
slug: initial-setup
|
||||||
|
|
|
@ -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]`
|
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.
|
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.
|
||||||
|
|
Reference in New Issue