diff --git a/authentik/blueprints/tests/test_v1_tasks.py b/authentik/blueprints/tests/test_v1_tasks.py index b78f62975aee..e249e8e08c9c 100644 --- a/authentik/blueprints/tests/test_v1_tasks.py +++ b/authentik/blueprints/tests/test_v1_tasks.py @@ -3,8 +3,10 @@ from hashlib import sha512 from pathlib import Path from tempfile import NamedTemporaryFile, mkdtemp +from unittest.mock import patch from django.test import TransactionTestCase +from django.utils.crypto import salted_hmac from yaml import dump from authentik.blueprints.models import BlueprintInstance, BlueprintInstanceStatus @@ -125,6 +127,49 @@ def test_valid_updated(self): }, ) + @CONFIG.patch("blueprints_dir", TMP) + def test_valid_updated_external_values(self): + """Test that changing only an `!Env` or `!File` value reapplies the blueprint""" + name = generate_id() + env_key = f"AUTHENTIK_TEST_{generate_id().upper()}" + with ( + NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file, + NamedTemporaryFile(mode="w+") as value_file, + patch.dict("os.environ", {env_key: "foo"}), + ): + value_file.write("foo") + value_file.flush() + content = ( + "version: 1\n" + f"metadata:\n name: {name}\n" + f"context:\n env: !Env {env_key}\n file: !File {value_file.name}\n" + "entries: []\n" + ) + file.write(content) + file.flush() + blueprints_discovery.send() + instance = BlueprintInstance.objects.get(name=name) + first_hash = instance.last_applied_hash + self.assertNotEqual(first_hash, sha512(content.encode()).hexdigest()) + + # Unchanged values don't reapply + blueprints_discovery.send() + instance.refresh_from_db() + self.assertEqual(instance.last_applied_hash, first_hash) + + with patch.dict("os.environ", {env_key: "bar"}): + blueprints_discovery.send() + instance.refresh_from_db() + env_hash = instance.last_applied_hash + self.assertNotEqual(env_hash, first_hash) + + value_file.seek(0) + value_file.write("bar") + value_file.flush() + blueprints_discovery.send() + instance.refresh_from_db() + self.assertNotIn(instance.last_applied_hash, [first_hash, env_hash]) + @CONFIG.patch("blueprints_dir", TMP) def test_valid_disabled(self): """Test valid file""" @@ -290,8 +335,13 @@ def test_file_tag_hashed_once_per_route(self): ): content = f"version: 1\nentries: []\ncontext:\n secret: {reference}\n" expected = sha512(content.encode()) - for _ in range(routes): - expected.update(sha512(b"initial").digest()) + expected.update( + salted_hmac( + "goauthentik.io/blueprints/v1/external-values", + sha512(b"initial").digest() * routes, + algorithm="sha512", + ).digest() + ) self.assertEqual(self.write_blueprint(file, reference), expected.hexdigest()) @CONFIG.patch("blueprints_dir", TMP) @@ -332,6 +382,8 @@ def test_file_tag_unreadable_discovery_continues(self): ("path from a mapping", f'!File {{path: "{TMP}/fallback"}}'), ("path no syscall can take", '!File "\\0"'), ("path outside the filesystem encoding", '!File "\\ud800"'), + ("env key with a null byte", '!Env "\\0"'), + ("env key outside the filesystem encoding", '!Env "\\ud800"'), ("sequence containing itself", "&anchor [*anchor]"), ("mapping containing itself", "&anchor {key: *anchor}"), ("two anchors containing each other", "&outer [{inner: &inner [*outer]}, *inner]"), diff --git a/authentik/blueprints/v1/hash.py b/authentik/blueprints/v1/hash.py index d6408545beec..34094160295c 100644 --- a/authentik/blueprints/v1/hash.py +++ b/authentik/blueprints/v1/hash.py @@ -2,23 +2,27 @@ from collections.abc import Generator from hashlib import sha512 +from os import getenv from pathlib import Path from typing import Any +from django.utils.crypto import salted_hmac from yaml import load from yaml.error import YAMLError -from authentik.blueprints.v1.common import BlueprintLoader, File, YAMLTag +from authentik.blueprints.v1.common import BlueprintLoader, Env, File, YAMLTag -def iter_file_tags(value: Any, ancestors: frozenset[int] = frozenset()) -> Generator[File]: - """Find all `!File` tags in a loaded blueprint, including tags used as arguments - of other tags. A node is not descended into again below itself; a node reached by - several routes is visited once per route.""" +def iter_external_tags( + value: Any, ancestors: frozenset[int] = frozenset() +) -> Generator[Env | File]: + """Find all `!Env` and `!File` tags in a loaded blueprint, including tags used as + arguments of other tags. A node is not descended into again below itself; a node + reached by several routes is visited once per route.""" if id(value) in ancestors: return ancestors = ancestors | {id(value)} - if isinstance(value, File): + if isinstance(value, Env | File): yield value if isinstance(value, dict): children = value.values() @@ -29,32 +33,47 @@ def iter_file_tags(value: Any, ancestors: frozenset[int] = frozenset()) -> Gener else: return for child in children: - yield from iter_file_tags(child, ancestors) + yield from iter_external_tags(child, ancestors) -# One byte where a readable reference contributes a 64-byte digest -MISSING_FILE_MARKER = b"\0" +# One byte where a readable value contributes a 64-byte digest +MISSING_VALUE_MARKER = b"\0" def blueprint_hash(content: str) -> str: - """Hash a blueprint's content and the contents of the files it references with - `!File` tags""" + """Hash a blueprint's content and the current values of its `!Env` and `!File` tags. + The values are keyed with the secret key, as the hash is visible in the API and a + plain hash of a short secret could be brute-forced.""" hasher = sha512(content.encode()) try: raw_blueprint = load(content, BlueprintLoader) except YAMLError: return hasher.hexdigest() - for tag in iter_file_tags(raw_blueprint): - # Mapping-node tags have no path; nested tags cannot be resolved here - path = getattr(tag, "path", None) - if not isinstance(path, str): - continue - try: - referenced = Path(path).read_bytes() - except OSError, ValueError: - # An unreadable reference still takes its place in the sequence, so a file - # that goes missing while another appears cannot leave the hash unchanged - hasher.update(MISSING_FILE_MARKER) - continue - hasher.update(sha512(referenced).digest()) + values = salted_hmac("goauthentik.io/blueprints/v1/external-values", b"", algorithm="sha512") + has_values = False + for tag in iter_external_tags(raw_blueprint): + # Mapping-node tags have no key or path; nested tags cannot be resolved here + if isinstance(tag, Env): + key = getattr(tag, "key", None) + if not isinstance(key, str): + continue + try: + env = getenv(key) + except ValueError: + env = None + value = None if env is None else env.encode(errors="surrogateescape") + else: + path = getattr(tag, "path", None) + if not isinstance(path, str): + continue + try: + value = Path(path).read_bytes() + except OSError, ValueError: + value = None + has_values = True + # A missing value still takes its place in the sequence, so a value that goes + # missing while another appears cannot leave the hash unchanged + values.update(MISSING_VALUE_MARKER if value is None else sha512(value).digest()) + if has_values: + hasher.update(values.digest()) return hasher.hexdigest() diff --git a/website/docs/customize/blueprints/index.mdx b/website/docs/customize/blueprints/index.mdx index f08e636a944d..a573c67b7abb 100644 --- a/website/docs/customize/blueprints/index.mdx +++ b/website/docs/customize/blueprints/index.mdx @@ -26,7 +26,7 @@ Multiple instances can be created from a single blueprint file. A blueprint inst authentik watches for file modification/creation events in the blueprint directory. When a new blueprint file is created, authentik will automatically trigger a _discovery_. A successful discovery will automatically create and apply a new blueprint instance. When a blueprint file is modified, authentik will trigger an _apply_ if an instance using this file exists. -authentik also scans the blueprint directory on an interval and compares each blueprint file to the copy in its database. Files that are new or modified will be discovered and applied. The default interval is 60 minutes. The interval can be changed in **System Tasks > Schedules** under `authentik.blueprints.v1.tasks.blueprints_discovery`. +authentik also scans the blueprint directory on an interval and compares each blueprint file to the copy in its database. Files that are new or modified will be discovered and applied. A blueprint is also applied again when a value it reads with [`!Env`](./v1/tags.mdx#env) or [`!File`](./v1/tags.mdx#file) changes, for example after a secret is rotated. The default interval is 60 minutes. The interval can be changed in **System Tasks > Schedules** under `authentik.blueprints.v1.tasks.blueprints_discovery`. :::info