diff --git a/.vscode/settings.json b/.vscode/settings.json index aeb3898d45..8cf2775688 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,8 @@ "!Enumerate sequence", "!Env scalar", "!Env sequence", + "!File scalar", + "!File sequence", "!Find sequence", "!Format sequence", "!If sequence", diff --git a/authentik/blueprints/tests/fixtures/tags.yaml b/authentik/blueprints/tests/fixtures/tags.yaml index 0017ae1174..ce6cacfe1e 100644 --- a/authentik/blueprints/tests/fixtures/tags.yaml +++ b/authentik/blueprints/tests/fixtures/tags.yaml @@ -12,8 +12,8 @@ context: context1: context-nested-value context2: !Context context1 entries: - - model: !Format ["%s", authentik_sources_oauth.oauthsource] - state: !Format ["%s", present] + - model: !Format ["%%s", authentik_sources_oauth.oauthsource] + state: !Format ["%%s", present] identifiers: slug: test attrs: @@ -27,20 +27,23 @@ entries: [slug, default-source-authentication], ] enrollment_flow: - !Find [!Format ["%s", authentik_flows.Flow], [slug, default-source-enrollment]] + !Find [!Format ["%%s", authentik_flows.Flow], [slug, default-source-enrollment]] - attrs: expression: return True identifiers: - name: !Format [foo-%s-%s-%s, !Context foo, !Context bar, qux] + name: !Format [foo-%%s-%%s-%%s, !Context foo, !Context bar, qux] id: policy model: authentik_policies_expression.expressionpolicy - attrs: attributes: env_null: !Env [bar-baz, null] + file_content: !File '%(file_name)s' + file_default: !File ['%(file_default_name)s', 'default'] + file_non_existent: !File '/does-not-exist' json_parse: !ParseJSON '{"foo": "bar"}' policy_pk1: !Format [ - "%s-%s", + "%%s-%%s", !Find [ authentik_policies_expression.expressionpolicy, [ @@ -51,29 +54,29 @@ entries: ], 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] + !Condition [AND, !Context foo, !Format ["%%s", "a_string"], 1] boolNand: - !Condition [NAND, !Context foo, !Format ["%s", "a_string"], 1] + !Condition [NAND, !Context foo, !Format ["%%s", "a_string"], 1] boolOr: !Condition [ OR, !Context foo, - !Format ["%s", "a_string"], + !Format ["%%s", "a_string"], null, ] boolNor: !Condition [ NOR, !Context foo, - !Format ["%s", "a_string"], + !Format ["%%s", "a_string"], null, ] boolXor: - !Condition [XOR, !Context foo, !Format ["%s", "a_string"], 1] + !Condition [XOR, !Context foo, !Format ["%%s", "a_string"], 1] boolXnor: - !Condition [XNOR, !Context foo, !Format ["%s", "a_string"], 1] + !Condition [XNOR, !Context foo, !Format ["%%s", "a_string"], 1] boolComplex: !Condition [ XNOR, @@ -89,7 +92,7 @@ entries: { with: { keys: "and_values" }, and_nested_custom_tags: - !Format ["foo-%s", !Context foo], + !Format ["foo-%%s", !Context foo], }, }, null, @@ -98,7 +101,7 @@ entries: !If [ !Condition [AND, false], null, - [list, with, items, !Format ["foo-%s", !Context foo]], + [list, with, items, !Format ["foo-%%s", !Context foo]], ] if_true_simple: !If [!Context foo, true, text] if_short: !If [!Context foo] @@ -106,22 +109,22 @@ entries: enumerate_mapping_to_mapping: !Enumerate [ !Context mapping, MAP, - [!Format ["prefix-%s", !Index 0], !Format ["other-prefix-%s", !Value 0]] + [!Format ["prefix-%%s", !Index 0], !Format ["other-prefix-%%s", !Value 0]] ] enumerate_mapping_to_sequence: !Enumerate [ !Context mapping, SEQ, - !Format ["prefixed-pair-%s-%s", !Index 0, !Value 0] + !Format ["prefixed-pair-%%s-%%s", !Index 0, !Value 0] ] enumerate_sequence_to_sequence: !Enumerate [ !Context sequence, SEQ, - !Format ["prefixed-items-%s-%s", !Index 0, !Value 0] + !Format ["prefixed-items-%%s-%%s", !Index 0, !Value 0] ] enumerate_sequence_to_mapping: !Enumerate [ !Context sequence, MAP, - [!Format ["index: %d", !Index 0], !Value 0] + [!Format ["index: %%d", !Index 0], !Value 0] ] nested_complex_enumeration: !Enumerate [ !Context sequence, @@ -132,9 +135,9 @@ entries: !Context mapping, MAP, [ - !Format ["%s", !Index 0], + !Format ["%%s", !Index 0], [ - !Enumerate [!Value 2, SEQ, !Format ["prefixed-%s", !Value 0]], + !Enumerate [!Value 2, SEQ, !Format ["prefixed-%%s", !Value 0]], { outer_value: !Value 1, outer_index: !Index 1, diff --git a/authentik/blueprints/tests/test_v1.py b/authentik/blueprints/tests/test_v1.py index a13ef51503..f2bc12b51a 100644 --- a/authentik/blueprints/tests/test_v1.py +++ b/authentik/blueprints/tests/test_v1.py @@ -1,6 +1,7 @@ """Test blueprints v1""" -from os import environ +from os import chmod, environ, unlink, write +from tempfile import mkstemp from django.test import TransactionTestCase @@ -131,97 +132,112 @@ class TestBlueprintsV1(TransactionTestCase): ExpressionPolicy.objects.filter(name="foo-bar-baz-qux").delete() Group.objects.filter(name="test").delete() environ["foo"] = generate_id() - importer = Importer.from_string(load_fixture("fixtures/tags.yaml"), {"bar": "baz"}) + file, file_name = mkstemp() + write(file, b"foo") + _, file_default_name = mkstemp() + chmod(file_default_name, 0o000) # Remove all permissions so we can't read the file + importer = Importer.from_string( + load_fixture( + "fixtures/tags.yaml", + file_name=file_name, + file_default_name=file_default_name, + ), + {"bar": "baz"}, + ) self.assertTrue(importer.validate()[0]) self.assertTrue(importer.apply()) policy = ExpressionPolicy.objects.filter(name="foo-bar-baz-qux").first() self.assertTrue(policy) - self.assertTrue( - Group.objects.filter( - 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, - "if_true_complex": { - "dictionary": { - "with": {"keys": "and_values"}, - "and_nested_custom_tags": "foo-bar", - } + group = Group.objects.filter(name="test").first() + self.assertIsNotNone(group) + self.assertEqual( + group.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, + "if_true_complex": { + "dictionary": { + "with": {"keys": "and_values"}, + "and_nested_custom_tags": "foo-bar", + } + }, + "if_false_complex": ["list", "with", "items", "foo-bar"], + "if_true_simple": True, + "if_short": True, + "if_false_simple": 2, + "enumerate_mapping_to_mapping": { + "prefix-key1": "other-prefix-value", + "prefix-key2": "other-prefix-2", + }, + "enumerate_mapping_to_sequence": [ + "prefixed-pair-key1-value", + "prefixed-pair-key2-2", + ], + "enumerate_sequence_to_sequence": [ + "prefixed-items-0-foo", + "prefixed-items-1-bar", + ], + "enumerate_sequence_to_mapping": {"index: 0": "foo", "index: 1": "bar"}, + "nested_complex_enumeration": { + "0": { + "key1": [ + ["prefixed-f", "prefixed-o", "prefixed-o"], + { + "outer_value": "foo", + "outer_index": 0, + "middle_value": "value", + "middle_index": "key1", + }, + ], + "key2": [ + ["prefixed-f", "prefixed-o", "prefixed-o"], + { + "outer_value": "foo", + "outer_index": 0, + "middle_value": 2, + "middle_index": "key2", + }, + ], }, - "if_false_complex": ["list", "with", "items", "foo-bar"], - "if_true_simple": True, - "if_short": True, - "if_false_simple": 2, - "enumerate_mapping_to_mapping": { - "prefix-key1": "other-prefix-value", - "prefix-key2": "other-prefix-2", + "1": { + "key1": [ + ["prefixed-b", "prefixed-a", "prefixed-r"], + { + "outer_value": "bar", + "outer_index": 1, + "middle_value": "value", + "middle_index": "key1", + }, + ], + "key2": [ + ["prefixed-b", "prefixed-a", "prefixed-r"], + { + "outer_value": "bar", + "outer_index": 1, + "middle_value": 2, + "middle_index": "key2", + }, + ], }, - "enumerate_mapping_to_sequence": [ - "prefixed-pair-key1-value", - "prefixed-pair-key2-2", - ], - "enumerate_sequence_to_sequence": [ - "prefixed-items-0-foo", - "prefixed-items-1-bar", - ], - "enumerate_sequence_to_mapping": {"index: 0": "foo", "index: 1": "bar"}, - "nested_complex_enumeration": { - "0": { - "key1": [ - ["prefixed-f", "prefixed-o", "prefixed-o"], - { - "outer_value": "foo", - "outer_index": 0, - "middle_value": "value", - "middle_index": "key1", - }, - ], - "key2": [ - ["prefixed-f", "prefixed-o", "prefixed-o"], - { - "outer_value": "foo", - "outer_index": 0, - "middle_value": 2, - "middle_index": "key2", - }, - ], - }, - "1": { - "key1": [ - ["prefixed-b", "prefixed-a", "prefixed-r"], - { - "outer_value": "bar", - "outer_index": 1, - "middle_value": "value", - "middle_index": "key1", - }, - ], - "key2": [ - ["prefixed-b", "prefixed-a", "prefixed-r"], - { - "outer_value": "bar", - "outer_index": 1, - "middle_value": 2, - "middle_index": "key2", - }, - ], - }, - }, - "nested_context": "context-nested-value", - "env_null": None, - "json_parse": {"foo": "bar"}, - "at_index_sequence": "foo", - "at_index_sequence_default": "non existent", - "at_index_mapping": 2, - "at_index_mapping_default": "non existent", - } - ).exists() + }, + "nested_context": "context-nested-value", + "env_null": None, + "file_content": "foo", + "file_default": "default", + "file_non_existent": None, + "json_parse": {"foo": "bar"}, + "at_index_sequence": "foo", + "at_index_sequence_default": "non existent", + "at_index_mapping": 2, + "at_index_mapping_default": "non existent", + }, ) self.assertTrue( OAuthSource.objects.filter( @@ -229,6 +245,8 @@ class TestBlueprintsV1(TransactionTestCase): consumer_key=environ["foo"], ) ) + unlink(file_name) + unlink(file_default_name) def test_export_validate_import_policies(self): """Test export and validate it""" diff --git a/authentik/blueprints/v1/common.py b/authentik/blueprints/v1/common.py index 056069cd40..d98927927e 100644 --- a/authentik/blueprints/v1/common.py +++ b/authentik/blueprints/v1/common.py @@ -18,12 +18,15 @@ from django.db.models import Model, Q from rest_framework.exceptions import ValidationError from rest_framework.fields import Field from rest_framework.serializers import Serializer +from structlog.stdlib import get_logger from yaml import SafeDumper, SafeLoader, ScalarNode, SequenceNode from authentik.lib.models import SerializerModel from authentik.lib.sentry import SentryIgnoredException from authentik.policies.models import PolicyBindingModel +LOGGER = get_logger() + class UNSET: """Used to test whether a key has not been set.""" @@ -268,6 +271,34 @@ class Env(YAMLTag): return getenv(self.key) or self.default +class File(YAMLTag): + """Lookup file with optional default""" + + path: str + default: Any | None + + def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None: + super().__init__() + self.default = None + if isinstance(node, ScalarNode): + self.path = node.value + if isinstance(node, SequenceNode): + self.path = loader.construct_object(node.value[0]) + self.default = loader.construct_object(node.value[1]) + + def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: + try: + with open(self.path, encoding="utf8") as _file: + return _file.read().strip() + except OSError as exc: + LOGGER.warning( + "Failed to read file. Falling back to default value", + path=self.path, + exc=exc, + ) + return self.default + + class Context(YAMLTag): """Lookup key from instance context""" @@ -679,6 +710,7 @@ class BlueprintLoader(SafeLoader): self.add_constructor("!Condition", Condition) self.add_constructor("!If", If) self.add_constructor("!Env", Env) + self.add_constructor("!File", File) self.add_constructor("!Enumerate", Enumerate) self.add_constructor("!Value", Value) self.add_constructor("!Index", Index) diff --git a/website/docs/customize/blueprints/v1/tags.mdx b/website/docs/customize/blueprints/v1/tags.mdx index 57bc65eabe..521ab030b9 100644 --- a/website/docs/customize/blueprints/v1/tags.mdx +++ b/website/docs/customize/blueprints/v1/tags.mdx @@ -11,6 +11,8 @@ For VS Code, for example, add these entries to your `settings.json`: "!Context scalar", "!Enumerate sequence", "!Env scalar", + "!File scalar", + "!File sequence", "!Find sequence", "!Format sequence", "!If sequence", @@ -44,6 +46,16 @@ password: !Env my_env_var Returns the value of the given environment variable. Can be used as a scalar with `!Env my_env_var, default` to return a default value. +#### `!File` + +Example: + +```yaml +password: !File /path/to/file +``` + +Returns the contents of the file at the given path. Can be used as a scalar with `!File /file/to/path, default` to return a default value. + #### `!Find` Examples: