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:
sdimovv 2022-12-21 17:04:00 +00:00 committed by GitHub
parent e9f5d7aefe
commit 7f662ac2f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 199 additions and 3 deletions

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
} }
) )
) )

View File

@ -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))

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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.