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",
|
||||
"!KeyOf scalar",
|
||||
"!Context scalar",
|
||||
"!Format sequence"
|
||||
"!Format sequence",
|
||||
"!Condition sequence"
|
||||
],
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"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:
|
||||
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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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 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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(<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)
|
||||
identifiers:
|
||||
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]`
|
||||
|
||||
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