From 79b0815ebac648081e8bf1e28ed290bcbb1976ea Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:04:25 +0000 Subject: [PATCH 01/12] blueprints: re-apply when a file referenced by `!File` changes The blueprint hash only covered the blueprint file itself, so a change to a file referenced through a `!File` tag - such as a Kubernetes Secret mounted into the container and then rotated - was never detected and the blueprint was not re-applied. Hash the referenced files' paths and contents alongside the blueprint's own content, in both places the hash is computed, so discovery and apply stay in agreement. Closes #26289 --- authentik/blueprints/tests/test_v1_tasks.py | 104 ++++++++++++++++++++ authentik/blueprints/v1/tasks.py | 58 ++++++++++- 2 files changed, 158 insertions(+), 4 deletions(-) diff --git a/authentik/blueprints/tests/test_v1_tasks.py b/authentik/blueprints/tests/test_v1_tasks.py index fe406b7aa9db..df8324f884ad 100644 --- a/authentik/blueprints/tests/test_v1_tasks.py +++ b/authentik/blueprints/tests/test_v1_tasks.py @@ -1,6 +1,7 @@ """Test blueprints v1 tasks""" from hashlib import sha512 +from pathlib import Path from tempfile import NamedTemporaryFile, mkdtemp from django.test import TransactionTestCase @@ -156,3 +157,106 @@ def test_valid_disabled(self): instance.status, BlueprintInstanceStatus.UNKNOWN, ) + + def write_blueprint(self, file, value: str): + """Write a blueprint referencing `value` and return its hash as found on disk""" + file.seek(0) + file.truncate() + file.write(f"version: 1\nentries: []\ncontext:\n secret: {value}\n") + file.flush() + blueprint = next(found for found in blueprints_find() if found.path == Path(file.name).name) + return blueprint.hash + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_content_changed(self): + """Test hash changes when the contents of a referenced `!File` change""" + with NamedTemporaryFile(mode="w+", dir=TMP) as secret: + secret.write("initial") + secret.flush() + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + before = self.write_blueprint(file, f"!File {secret.name}") + secret.seek(0) + secret.truncate() + secret.write("rotated") + secret.flush() + after = self.write_blueprint(file, f"!File {secret.name}") + self.assertNotEqual(before, after) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_content_changed_nested(self): + """Test hash changes when a `!File` used as an argument of another tag changes""" + with NamedTemporaryFile(mode="w+", dir=TMP) as secret: + secret.write("initial") + secret.flush() + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + nested = f'!Format ["client-%s", !File {secret.name}]' + before = self.write_blueprint(file, nested) + secret.seek(0) + secret.truncate() + secret.write("rotated") + secret.flush() + after = self.write_blueprint(file, nested) + self.assertNotEqual(before, after) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_created(self): + """Test hash changes when a referenced `!File` that was missing appears""" + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + secret_path = Path(TMP) / generate_id() + reference = f"!File {secret_path}" + before = self.write_blueprint(file, reference) + secret_path.write_text("created") + try: + after = self.write_blueprint(file, reference) + finally: + secret_path.unlink() + self.assertNotEqual(before, after) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_content_unchanged(self): + """Test hash is stable when a referenced `!File` does not change (control)""" + with NamedTemporaryFile(mode="w+", dir=TMP) as secret: + secret.write("initial") + secret.flush() + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + reference = f"!File {secret.name}" + self.assertEqual( + self.write_blueprint(file, reference), + self.write_blueprint(file, reference), + ) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_missing(self): + """Test hash is stable when a referenced `!File` does not exist (control)""" + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + reference = f"!File {Path(TMP) / generate_id()}" + self.assertEqual( + self.write_blueprint(file, reference), + self.write_blueprint(file, reference), + ) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_applied_on_change(self): + """Test blueprint is re-applied when the contents of a referenced `!File` change""" + blueprint_id = generate_id() + with NamedTemporaryFile(mode="w+", dir=TMP) as secret: + secret.write("initial") + secret.flush() + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + file.write( + f"version: 1\nentries: []\n" + f"metadata:\n name: {blueprint_id}\n" + f"context:\n secret: !File {secret.name}\n" + ) + file.flush() + blueprints_discovery.send() + instance = BlueprintInstance.objects.filter(name=blueprint_id).first() + before = instance.last_applied_hash + self.assertEqual(instance.status, BlueprintInstanceStatus.SUCCESSFUL) + secret.seek(0) + secret.truncate() + secret.write("rotated") + secret.flush() + blueprints_discovery.send() + instance.refresh_from_db() + self.assertNotEqual(instance.last_applied_hash, before) diff --git a/authentik/blueprints/v1/tasks.py b/authentik/blueprints/v1/tasks.py index 2b4e8baaa49d..2f6e76ae766f 100644 --- a/authentik/blueprints/v1/tasks.py +++ b/authentik/blueprints/v1/tasks.py @@ -1,9 +1,11 @@ """v1 blueprints tasks""" +from collections.abc import Generator from dataclasses import asdict, dataclass, field from hashlib import sha512 from pathlib import Path from sys import platform +from typing import Any from uuid import UUID from dacite.core import from_dict @@ -30,7 +32,13 @@ BlueprintInstanceStatus, BlueprintRetrievalFailed, ) -from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata, EntryInvalidError +from authentik.blueprints.v1.common import ( + BlueprintLoader, + BlueprintMetadata, + EntryInvalidError, + File, + YAMLTag, +) from authentik.blueprints.v1.importer import Importer from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_INSTANTIATE from authentik.blueprints.v1.oci import OCI_PREFIX @@ -56,6 +64,47 @@ class BlueprintFile: meta: BlueprintMetadata | None = field(default=None) +def iter_file_tags(value: Any) -> Generator[File]: + """Find all `!File` tags in a loaded blueprint, including tags used as arguments + of other tags""" + if isinstance(value, File): + yield value + if isinstance(value, dict): + children = value.values() + elif isinstance(value, list | tuple): + children = value + elif isinstance(value, YAMLTag): + children = vars(value).values() + else: + return + for child in children: + yield from iter_file_tags(child) + + +def blueprint_hash(content: str) -> str: + """Hash a blueprint's content, including the contents of the files it references with + `!File` tags. Those files are not part of the blueprint itself, so hashing the content + alone means a changed file (such as a rotated secret mounted into the container) is + never detected as a change and the blueprint is never re-applied.""" + hasher = sha512(content.encode()) + try: + raw_blueprint = load(content, BlueprintLoader) + except YAMLError: + return hasher.hexdigest() + for tag in iter_file_tags(raw_blueprint): + # Digest both the path and the referenced file's contents, so that neither a + # changed path nor changed contents can be cancelled out by the other + hasher.update(sha512(str(tag.path).encode()).digest()) + try: + referenced = Path(tag.path).read_bytes() + except OSError: + # The file can't be read, so the tag resolves to its default value, which is + # part of the content hashed above + continue + hasher.update(sha512(referenced).digest()) + return hasher.hexdigest() + + class BlueprintWatcherMiddleware(Middleware): def start_blueprint_watcher(self): """Start blueprint watcher""" @@ -129,8 +178,9 @@ def blueprints_find() -> list[BlueprintFile]: if any(part for part in rel_path.parts if part.startswith(".")): continue with open(path, encoding="utf-8") as blueprint_file: + content = blueprint_file.read() try: - raw_blueprint = load(blueprint_file.read(), BlueprintLoader) + raw_blueprint = load(content, BlueprintLoader) except YAMLError as exc: raw_blueprint = None LOGGER.warning("failed to parse blueprint", exc=exc, path=str(rel_path)) @@ -141,7 +191,7 @@ def blueprints_find() -> list[BlueprintFile]: if version != 1: LOGGER.warning("invalid blueprint version", version=version, path=str(rel_path)) continue - file_hash = sha512(path.read_bytes()).hexdigest() + file_hash = blueprint_hash(content) blueprint = BlueprintFile(str(rel_path), version, file_hash, int(path.stat().st_mtime)) blueprint.meta = from_dict(BlueprintMetadata, metadata) if metadata else None blueprints.append(blueprint) @@ -202,7 +252,7 @@ def apply_blueprint(instance_pk: UUID): self.info(f"Blueprint {instance.name} is disabled, skipping") return blueprint_content = instance.retrieve() - file_hash = sha512(blueprint_content.encode()).hexdigest() + file_hash = blueprint_hash(blueprint_content) importer = Importer.from_string(blueprint_content, instance.context) if importer.blueprint.metadata: instance.metadata = asdict(importer.blueprint.metadata) From a2decb52d158d3a09c365e537e37a682c93b65b7 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:57:21 +0000 Subject: [PATCH 02/12] blueprints: skip `!File` tags whose path is itself a tag when hashing `File.__init__` assigns `self.path` from `loader.construct_object()` when the tag is built from a sequence node, so `!File [!Env P, default]` carries a tag object rather than a string. `Path()` raises `TypeError` on it, which the surrounding `except OSError` does not catch, and `blueprints_find` has no per-file guard - so one such blueprint would abort discovery for all of them. Skip those tags: their own content is already part of the blueprint content hashed above, and resolving them needs an entry and a blueprint that the hashing path does not have. --- authentik/blueprints/tests/test_v1_tasks.py | 10 ++++++++++ authentik/blueprints/v1/tasks.py | 7 ++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/authentik/blueprints/tests/test_v1_tasks.py b/authentik/blueprints/tests/test_v1_tasks.py index df8324f884ad..aca741e80be9 100644 --- a/authentik/blueprints/tests/test_v1_tasks.py +++ b/authentik/blueprints/tests/test_v1_tasks.py @@ -235,6 +235,16 @@ def test_file_tag_missing(self): self.write_blueprint(file, reference), ) + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_path_from_tag(self): + """Test a `!File` whose path is itself a tag is still discovered (control)""" + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + reference = f'!File [!Env [{generate_id()}, "{TMP}/fallback"], "default"]' + self.assertEqual( + self.write_blueprint(file, reference), + self.write_blueprint(file, reference), + ) + @CONFIG.patch("blueprints_dir", TMP) def test_file_tag_applied_on_change(self): """Test blueprint is re-applied when the contents of a referenced `!File` change""" diff --git a/authentik/blueprints/v1/tasks.py b/authentik/blueprints/v1/tasks.py index 2f6e76ae766f..168f9b3ec328 100644 --- a/authentik/blueprints/v1/tasks.py +++ b/authentik/blueprints/v1/tasks.py @@ -92,9 +92,14 @@ def blueprint_hash(content: str) -> str: except YAMLError: return hasher.hexdigest() for tag in iter_file_tags(raw_blueprint): + if not isinstance(tag.path, str): + # The path is itself a tag, which can only be resolved with an entry and a + # blueprint. Hashing must never fail on a blueprint that can be loaded, so + # skip it; the tag's own content is part of the content hashed above. + continue # Digest both the path and the referenced file's contents, so that neither a # changed path nor changed contents can be cancelled out by the other - hasher.update(sha512(str(tag.path).encode()).digest()) + hasher.update(sha512(tag.path.encode()).digest()) try: referenced = Path(tag.path).read_bytes() except OSError: From 44d6580f30ea7c694507d5a988e559ac9bb945d5 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:02:42 +0000 Subject: [PATCH 03/12] blueprints: don't let an unreadable `!File` path abort blueprint discovery The previous commit's guard reads `tag.path` to decide whether to skip the tag, but `File.__init__` assigns `self.path` only for scalar and sequence nodes; `path` is otherwise just a class annotation, which creates no attribute. A `!File` built from a mapping node takes neither branch, so the guard raises `AttributeError` before it can guard anything. Read the attribute with `getattr()` so the check cannot be what fails. `Path(...).read_bytes()` also raises `ValueError`, not `OSError`, for a path no syscall can accept, such as one containing a null byte, so widen the except to cover it. Both inputs parse cleanly and hash without complaint before this series, and neither `blueprints_find` nor `apply_blueprint` catches either exception, so a single such blueprint would stop every other blueprint from being discovered. Drop the separate digest of the path while here: the path of a tag that is hashed is a substring of the blueprint content already hashed above, so it cannot contribute anything the content digest does not. --- authentik/blueprints/tests/test_v1_tasks.py | 46 +++++++++++++++++++++ authentik/blueprints/v1/tasks.py | 26 +++++++----- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/authentik/blueprints/tests/test_v1_tasks.py b/authentik/blueprints/tests/test_v1_tasks.py index aca741e80be9..a71c2636de4e 100644 --- a/authentik/blueprints/tests/test_v1_tasks.py +++ b/authentik/blueprints/tests/test_v1_tasks.py @@ -270,3 +270,49 @@ def test_file_tag_applied_on_change(self): blueprints_discovery.send() instance.refresh_from_db() self.assertNotEqual(instance.last_applied_hash, before) + + def assert_discovery_survives(self, reference: str): + """Assert a blueprint referencing `reference` neither breaks its own hashing nor + stops a healthy blueprint alongside it from being discovered""" + healthy_id = generate_id() + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as broken: + broken.write(f"version: 1\nentries: []\ncontext:\n secret: {reference}\n") + broken.flush() + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as healthy: + healthy.write(f"version: 1\nentries: []\nmetadata:\n name: {healthy_id}\n") + healthy.flush() + found = [blueprint.path for blueprint in blueprints_find()] + self.assertIn(Path(healthy.name).name, found) + self.assertIn(Path(broken.name).name, found) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_path_from_mapping(self): + """Test a `!File` built from a mapping node is skipped rather than raising, so + discovery of other blueprints continues (control)""" + self.assert_discovery_survives(f'!File {{path: "{TMP}/fallback"}}') + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_path_unopenable(self): + """Test a `!File` whose path cannot be opened by any syscall is skipped rather + than raising, so discovery of other blueprints continues (control)""" + self.assert_discovery_survives('!File "\\0"') + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_path_from_mapping_stable(self): + """Test the hash of a `!File` built from a mapping node is stable (control)""" + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + reference = f'!File {{path: "{TMP}/fallback"}}' + self.assertEqual( + self.write_blueprint(file, reference), + self.write_blueprint(file, reference), + ) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_path_unopenable_stable(self): + """Test the hash of a `!File` with an unopenable path is stable (control)""" + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + reference = '!File "\\0"' + self.assertEqual( + self.write_blueprint(file, reference), + self.write_blueprint(file, reference), + ) diff --git a/authentik/blueprints/v1/tasks.py b/authentik/blueprints/v1/tasks.py index 168f9b3ec328..f9d9d75672e2 100644 --- a/authentik/blueprints/v1/tasks.py +++ b/authentik/blueprints/v1/tasks.py @@ -92,20 +92,24 @@ def blueprint_hash(content: str) -> str: except YAMLError: return hasher.hexdigest() for tag in iter_file_tags(raw_blueprint): - if not isinstance(tag.path, str): - # The path is itself a tag, which can only be resolved with an entry and a - # blueprint. Hashing must never fail on a blueprint that can be loaded, so - # skip it; the tag's own content is part of the content hashed above. + # `File.__init__` assigns `path` only for scalar and sequence nodes, so a `!File` + # built from any other node has no `path` attribute at all, and a path taken from + # a nested tag is a tag rather than a string. Neither can be read without an entry + # and a blueprint. Hashing must never fail on a blueprint that can be loaded, so + # skip them; the tag's own content is part of the content hashed above. Read the + # attribute defensively - the check itself must not be what raises. + path = getattr(tag, "path", None) + if not isinstance(path, str): continue - # Digest both the path and the referenced file's contents, so that neither a - # changed path nor changed contents can be cancelled out by the other - hasher.update(sha512(tag.path.encode()).digest()) try: - referenced = Path(tag.path).read_bytes() - except OSError: - # The file can't be read, so the tag resolves to its default value, which is - # part of the content hashed above + referenced = Path(path).read_bytes() + except (OSError, ValueError): + # The file can't be read - `ValueError` for a path no syscall can take, such + # as one containing a null byte - so the tag resolves to its default value, + # which is part of the content hashed above continue + # Only the referenced contents need digesting; the path itself is a substring of + # the content already hashed above hasher.update(sha512(referenced).digest()) return hasher.hexdigest() From ed629ce2dc8ee58eafe057c848c801b703272d26 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:53:54 +0000 Subject: [PATCH 04/12] blueprints: bound the `!File` tag walk on cyclic blueprints YAML anchors and aliases let a node be its own descendant, and PyYAML builds collections in two steps precisely so such documents load. `iter_file_tags` recurses with no bound, so hashing a blueprint like context: secret: &anchor - *anchor raises `RecursionError`. `blueprints_find` has no per-file guard around `blueprint_hash` and `apply_blueprint` does not catch `RecursionError`, so one such blueprint stops every other blueprint from being discovered - and the document hashes without complaint before this series, so this would be a regression rather than a pre-existing limitation. Skip a node already on the path from the root. A node shared by two disjoint routes - an ordinary blueprint reusing one anchor - is not its own ancestor, so it is still walked from each route and its digest is unchanged; suppressing those instead would silently move the hash of every aliased blueprint in the wild and re-apply each one once. Spell the widened `except` as `black` at the version pinned in pyproject.toml formats it. --- authentik/blueprints/tests/test_v1_tasks.py | 54 +++++++++++++++++++++ authentik/blueprints/v1/tasks.py | 14 ++++-- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/authentik/blueprints/tests/test_v1_tasks.py b/authentik/blueprints/tests/test_v1_tasks.py index a71c2636de4e..bfa406d3cf45 100644 --- a/authentik/blueprints/tests/test_v1_tasks.py +++ b/authentik/blueprints/tests/test_v1_tasks.py @@ -316,3 +316,57 @@ def test_file_tag_path_unopenable_stable(self): self.write_blueprint(file, reference), self.write_blueprint(file, reference), ) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_cycle_content_changed(self): + """Test hash changes when a `!File` reached through a cyclic anchor changes""" + with NamedTemporaryFile(mode="w+", dir=TMP) as secret: + secret.write("initial") + secret.flush() + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + cycle = f"&anchor [*anchor, !File {secret.name}]" + before = self.write_blueprint(file, cycle) + secret.seek(0) + secret.truncate() + secret.write("rotated") + secret.flush() + after = self.write_blueprint(file, cycle) + self.assertNotEqual(before, after) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_alias_content_changed(self): + """Test hash changes when a `!File` reachable only through an alias changes""" + with NamedTemporaryFile(mode="w+", dir=TMP) as secret: + secret.write("initial") + secret.flush() + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + alias = f"&anchor [!File {secret.name}]\n other: *anchor" + before = self.write_blueprint(file, alias) + secret.seek(0) + secret.truncate() + secret.write("rotated") + secret.flush() + after = self.write_blueprint(file, alias) + self.assertNotEqual(before, after) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_cycle_sequence(self): + """Test a blueprint whose anchor makes a sequence contain itself is hashed + rather than raising, so discovery of other blueprints continues""" + self.assert_discovery_survives("&anchor [*anchor]") + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_cycle_mapping(self): + """Test a blueprint whose anchor makes a mapping contain itself is hashed + rather than raising, so discovery of other blueprints continues""" + self.assert_discovery_survives("&anchor {key: *anchor}") + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_deeply_nested(self): + """Test a deeply nested blueprint with no cycle is hashed (control)""" + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + reference = "[" * 50 + f'!File "{TMP}/fallback"' + "]" * 50 + self.assertEqual( + self.write_blueprint(file, reference), + self.write_blueprint(file, reference), + ) diff --git a/authentik/blueprints/v1/tasks.py b/authentik/blueprints/v1/tasks.py index f9d9d75672e2..f1882ce3dd95 100644 --- a/authentik/blueprints/v1/tasks.py +++ b/authentik/blueprints/v1/tasks.py @@ -64,9 +64,15 @@ class BlueprintFile: meta: BlueprintMetadata | None = field(default=None) -def iter_file_tags(value: Any) -> Generator[File]: +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""" + of other tags. Anchors and aliases let a node contain itself, so a node already on + the path from the root is not descended into again; a node shared by two disjoint + routes is not its own ancestor and is still walked from each of them, as it is + without this check.""" + if id(value) in ancestors: + return + ancestors = ancestors | {id(value)} if isinstance(value, File): yield value if isinstance(value, dict): @@ -78,7 +84,7 @@ def iter_file_tags(value: Any) -> Generator[File]: else: return for child in children: - yield from iter_file_tags(child) + yield from iter_file_tags(child, ancestors) def blueprint_hash(content: str) -> str: @@ -103,7 +109,7 @@ def blueprint_hash(content: str) -> str: continue try: referenced = Path(path).read_bytes() - except (OSError, ValueError): + except OSError, ValueError: # The file can't be read - `ValueError` for a path no syscall can take, such # as one containing a null byte - so the tag resolves to its default value, # which is part of the content hashed above From 1c67effb19fe545a0876a57b77bfa4e8e424a101 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:56:41 +0000 Subject: [PATCH 05/12] blueprints: pin the per-route hashing of an aliased `!File` tag A node reached by two routes through one anchor is walked from each of them, so the file it references is folded into the digest twice, exactly as it is when the tag is written out twice. Bounding the walk by the ancestor chain keeps that; suppressing every node seen anywhere would fold it in once and move the hash of every blueprint that reuses an anchor. Compute the expected digest from the blueprint's own content rather than comparing two runs, so the test pins the count and not just stability. --- authentik/blueprints/tests/test_v1_tasks.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/authentik/blueprints/tests/test_v1_tasks.py b/authentik/blueprints/tests/test_v1_tasks.py index bfa406d3cf45..36bebbd4d5dc 100644 --- a/authentik/blueprints/tests/test_v1_tasks.py +++ b/authentik/blueprints/tests/test_v1_tasks.py @@ -370,3 +370,18 @@ def test_file_tag_deeply_nested(self): self.write_blueprint(file, reference), self.write_blueprint(file, reference), ) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_alias_hashed_per_route(self): + """Test a `!File` reachable by two routes through one anchor is folded into the + hash once per route, as it is when the tag is simply written out twice""" + with NamedTemporaryFile(mode="w+", dir=TMP) as secret: + secret.write("initial") + secret.flush() + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + alias = f"&anchor [!File {secret.name}]\n other: *anchor" + content = f"version: 1\nentries: []\ncontext:\n secret: {alias}\n" + expected = sha512(content.encode()) + for _ in range(2): + expected.update(sha512(b"initial").digest()) + self.assertEqual(self.write_blueprint(file, alias), expected.hexdigest()) From 3ad347d1b522eec9c56df16b80d32a8a42e1219d Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:28:12 +0000 Subject: [PATCH 06/12] blueprints: describe the `!File` hash tests by what they assert The stability cases read as annotations of a patch rather than as tests of the hash, which is not how the rest of the file reads. --- authentik/blueprints/tests/test_v1_tasks.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/authentik/blueprints/tests/test_v1_tasks.py b/authentik/blueprints/tests/test_v1_tasks.py index 36bebbd4d5dc..a01b49818986 100644 --- a/authentik/blueprints/tests/test_v1_tasks.py +++ b/authentik/blueprints/tests/test_v1_tasks.py @@ -214,7 +214,7 @@ def test_file_tag_created(self): @CONFIG.patch("blueprints_dir", TMP) def test_file_tag_content_unchanged(self): - """Test hash is stable when a referenced `!File` does not change (control)""" + """Test hash is stable when a referenced `!File` does not change""" with NamedTemporaryFile(mode="w+", dir=TMP) as secret: secret.write("initial") secret.flush() @@ -227,7 +227,7 @@ def test_file_tag_content_unchanged(self): @CONFIG.patch("blueprints_dir", TMP) def test_file_tag_missing(self): - """Test hash is stable when a referenced `!File` does not exist (control)""" + """Test hash is stable when a referenced `!File` does not exist""" with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: reference = f"!File {Path(TMP) / generate_id()}" self.assertEqual( @@ -237,7 +237,7 @@ def test_file_tag_missing(self): @CONFIG.patch("blueprints_dir", TMP) def test_file_tag_path_from_tag(self): - """Test a `!File` whose path is itself a tag is still discovered (control)""" + """Test a `!File` whose path is itself a tag is still discovered""" with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: reference = f'!File [!Env [{generate_id()}, "{TMP}/fallback"], "default"]' self.assertEqual( @@ -288,18 +288,18 @@ def assert_discovery_survives(self, reference: str): @CONFIG.patch("blueprints_dir", TMP) def test_file_tag_path_from_mapping(self): """Test a `!File` built from a mapping node is skipped rather than raising, so - discovery of other blueprints continues (control)""" + discovery of other blueprints continues""" self.assert_discovery_survives(f'!File {{path: "{TMP}/fallback"}}') @CONFIG.patch("blueprints_dir", TMP) def test_file_tag_path_unopenable(self): """Test a `!File` whose path cannot be opened by any syscall is skipped rather - than raising, so discovery of other blueprints continues (control)""" + than raising, so discovery of other blueprints continues""" self.assert_discovery_survives('!File "\\0"') @CONFIG.patch("blueprints_dir", TMP) def test_file_tag_path_from_mapping_stable(self): - """Test the hash of a `!File` built from a mapping node is stable (control)""" + """Test the hash of a `!File` built from a mapping node is stable""" with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: reference = f'!File {{path: "{TMP}/fallback"}}' self.assertEqual( @@ -309,7 +309,7 @@ def test_file_tag_path_from_mapping_stable(self): @CONFIG.patch("blueprints_dir", TMP) def test_file_tag_path_unopenable_stable(self): - """Test the hash of a `!File` with an unopenable path is stable (control)""" + """Test the hash of a `!File` with an unopenable path is stable""" with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: reference = '!File "\\0"' self.assertEqual( @@ -363,7 +363,7 @@ def test_file_tag_cycle_mapping(self): @CONFIG.patch("blueprints_dir", TMP) def test_file_tag_deeply_nested(self): - """Test a deeply nested blueprint with no cycle is hashed (control)""" + """Test a deeply nested blueprint with no cycle is hashed""" with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: reference = "[" * 50 + f'!File "{TMP}/fallback"' + "]" * 50 self.assertEqual( From e0bdb02a4132cc00262147308d735272272fee3d Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:59:37 +0000 Subject: [PATCH 07/12] blueprints: pin the `!File` hash on file removal, swapped contents and cycle fold count --- authentik/blueprints/tests/test_v1_tasks.py | 50 +++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/authentik/blueprints/tests/test_v1_tasks.py b/authentik/blueprints/tests/test_v1_tasks.py index a01b49818986..5283cc7a600e 100644 --- a/authentik/blueprints/tests/test_v1_tasks.py +++ b/authentik/blueprints/tests/test_v1_tasks.py @@ -385,3 +385,53 @@ def test_file_tag_alias_hashed_per_route(self): for _ in range(2): expected.update(sha512(b"initial").digest()) self.assertEqual(self.write_blueprint(file, alias), expected.hexdigest()) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_removed(self): + """Test hash changes when a referenced `!File` that existed disappears""" + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + secret_path = Path(TMP) / generate_id() + secret_path.write_text("present") + reference = f"!File {secret_path}" + try: + before = self.write_blueprint(file, reference) + finally: + secret_path.unlink() + after = self.write_blueprint(file, reference) + self.assertNotEqual(before, after) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_contents_swapped(self): + """Test hash changes when two referenced `!File`s exchange their contents""" + with ( + NamedTemporaryFile(mode="w+", dir=TMP) as first, + NamedTemporaryFile(mode="w+", dir=TMP) as second, + ): + first.write("alpha") + first.flush() + second.write("beta") + second.flush() + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + reference = f"[!File {first.name}, !File {second.name}]" + before = self.write_blueprint(file, reference) + for secret, value in ((first, "beta"), (second, "alpha")): + secret.seek(0) + secret.truncate() + secret.write(value) + secret.flush() + after = self.write_blueprint(file, reference) + self.assertNotEqual(before, after) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_cycle_hashed_once(self): + """Test a `!File` beside a node that contains itself is folded into the hash + exactly once, however many times the cycle could be followed""" + with NamedTemporaryFile(mode="w+", dir=TMP) as secret: + secret.write("initial") + secret.flush() + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + cycle = f"&anchor [*anchor, !File {secret.name}]" + content = f"version: 1\nentries: []\ncontext:\n secret: {cycle}\n" + expected = sha512(content.encode()) + expected.update(sha512(b"initial").digest()) + self.assertEqual(self.write_blueprint(file, cycle), expected.hexdigest()) From c4c91590a5c80d561706292fed262f80e1652edc Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:00:41 +0000 Subject: [PATCH 08/12] blueprints: fold the `!File` hash tests into the file's idiom The tests covering the `!File` hashing added cases one per input, which made the test diff several times the size of the change it covers. Group the ones that differ only in the reference they hash into subtests, keeping every input, and drop the docstrings from the test-local helpers. --- authentik/blueprints/tests/test_v1_tasks.py | 327 +++++++------------- 1 file changed, 109 insertions(+), 218 deletions(-) diff --git a/authentik/blueprints/tests/test_v1_tasks.py b/authentik/blueprints/tests/test_v1_tasks.py index 5283cc7a600e..7ec7b35a6239 100644 --- a/authentik/blueprints/tests/test_v1_tasks.py +++ b/authentik/blueprints/tests/test_v1_tasks.py @@ -159,44 +159,36 @@ def test_valid_disabled(self): ) def write_blueprint(self, file, value: str): - """Write a blueprint referencing `value` and return its hash as found on disk""" file.seek(0) file.truncate() file.write(f"version: 1\nentries: []\ncontext:\n secret: {value}\n") file.flush() - blueprint = next(found for found in blueprints_find() if found.path == Path(file.name).name) - return blueprint.hash + return next(found for found in blueprints_find() if found.path == Path(file.name).name).hash + + def write_secret(self, file, value: str): + file.seek(0) + file.truncate() + file.write(value) + file.flush() @CONFIG.patch("blueprints_dir", TMP) def test_file_tag_content_changed(self): """Test hash changes when the contents of a referenced `!File` change""" with NamedTemporaryFile(mode="w+", dir=TMP) as secret: - secret.write("initial") - secret.flush() - with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: - before = self.write_blueprint(file, f"!File {secret.name}") - secret.seek(0) - secret.truncate() - secret.write("rotated") - secret.flush() - after = self.write_blueprint(file, f"!File {secret.name}") - self.assertNotEqual(before, after) - - @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_content_changed_nested(self): - """Test hash changes when a `!File` used as an argument of another tag changes""" - with NamedTemporaryFile(mode="w+", dir=TMP) as secret: - secret.write("initial") - secret.flush() - with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: - nested = f'!Format ["client-%s", !File {secret.name}]' - before = self.write_blueprint(file, nested) - secret.seek(0) - secret.truncate() - secret.write("rotated") - secret.flush() - after = self.write_blueprint(file, nested) - self.assertNotEqual(before, after) + for label, reference in ( + ("direct", f"!File {secret.name}"), + ("argument of another tag", f'!Format ["client-%s", !File {secret.name}]'), + ("reached through a cycle", f"&anchor [*anchor, !File {secret.name}]"), + ("reached through an alias", f"&anchor [!File {secret.name}]\n other: *anchor"), + ): + with ( + self.subTest(label), + NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file, + ): + self.write_secret(secret, "initial") + before = self.write_blueprint(file, reference) + self.write_secret(secret, "rotated") + self.assertNotEqual(before, self.write_blueprint(file, reference)) @CONFIG.patch("blueprints_dir", TMP) def test_file_tag_created(self): @@ -212,12 +204,59 @@ def test_file_tag_created(self): secret_path.unlink() self.assertNotEqual(before, after) + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_removed(self): + """Test hash changes when a referenced `!File` that existed disappears""" + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + secret_path = Path(TMP) / generate_id() + secret_path.write_text("present") + reference = f"!File {secret_path}" + try: + before = self.write_blueprint(file, reference) + finally: + secret_path.unlink() + self.assertNotEqual(before, self.write_blueprint(file, reference)) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_contents_swapped(self): + """Test hash changes when two referenced `!File`s exchange their contents""" + with ( + NamedTemporaryFile(mode="w+", dir=TMP) as first, + NamedTemporaryFile(mode="w+", dir=TMP) as second, + ): + self.write_secret(first, "alpha") + self.write_secret(second, "beta") + with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: + reference = f"[!File {first.name}, !File {second.name}]" + before = self.write_blueprint(file, reference) + self.write_secret(first, "beta") + self.write_secret(second, "alpha") + self.assertNotEqual(before, self.write_blueprint(file, reference)) + + @CONFIG.patch("blueprints_dir", TMP) + def test_file_tag_hashed_once_per_route(self): + """Test a referenced `!File` is folded into the hash once for each route to it""" + with NamedTemporaryFile(mode="w+", dir=TMP) as secret: + self.write_secret(secret, "initial") + for label, reference, routes in ( + ("cycle", f"&anchor [*anchor, !File {secret.name}]", 1), + ("alias", f"&anchor [!File {secret.name}]\n other: *anchor", 2), + ): + with ( + self.subTest(label), + NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file, + ): + content = f"version: 1\nentries: []\ncontext:\n secret: {reference}\n" + expected = sha512(content.encode()) + for _ in range(routes): + expected.update(sha512(b"initial").digest()) + self.assertEqual(self.write_blueprint(file, reference), expected.hexdigest()) + @CONFIG.patch("blueprints_dir", TMP) def test_file_tag_content_unchanged(self): """Test hash is stable when a referenced `!File` does not change""" with NamedTemporaryFile(mode="w+", dir=TMP) as secret: - secret.write("initial") - secret.flush() + self.write_secret(secret, "initial") with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: reference = f"!File {secret.name}" self.assertEqual( @@ -226,32 +265,52 @@ def test_file_tag_content_unchanged(self): ) @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_missing(self): - """Test hash is stable when a referenced `!File` does not exist""" - with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: - reference = f"!File {Path(TMP) / generate_id()}" - self.assertEqual( - self.write_blueprint(file, reference), - self.write_blueprint(file, reference), - ) + def test_file_tag_unreadable_hash_stable(self): + """Test hash is stable when a referenced `!File` cannot be read""" + for label, reference in ( + ("missing file", f"!File {Path(TMP) / generate_id()}"), + ("path from a tag", f'!File [!Env [{generate_id()}, "{TMP}/fallback"], "default"]'), + ("path from a mapping", f'!File {{path: "{TMP}/fallback"}}'), + ("path no syscall can take", '!File "\\0"'), + ("deeply nested", "[" * 50 + f'!File "{TMP}/fallback"' + "]" * 50), + ): + with ( + self.subTest(label), + NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file, + ): + self.assertEqual( + self.write_blueprint(file, reference), + self.write_blueprint(file, reference), + ) @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_path_from_tag(self): - """Test a `!File` whose path is itself a tag is still discovered""" - with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: - reference = f'!File [!Env [{generate_id()}, "{TMP}/fallback"], "default"]' - self.assertEqual( - self.write_blueprint(file, reference), - self.write_blueprint(file, reference), - ) + def test_file_tag_unreadable_discovery_continues(self): + """Test a blueprint that cannot be hashed does not stop others being discovered""" + for label, reference in ( + ("path from a mapping", f'!File {{path: "{TMP}/fallback"}}'), + ("path no syscall can take", '!File "\\0"'), + ("sequence containing itself", "&anchor [*anchor]"), + ("mapping containing itself", "&anchor {key: *anchor}"), + ): + with ( + self.subTest(label), + NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as broken, + NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as healthy, + ): + broken.write(f"version: 1\nentries: []\ncontext:\n secret: {reference}\n") + broken.flush() + healthy.write(f"version: 1\nentries: []\nmetadata:\n name: {generate_id()}\n") + healthy.flush() + found = [blueprint.path for blueprint in blueprints_find()] + self.assertIn(Path(healthy.name).name, found) + self.assertIn(Path(broken.name).name, found) @CONFIG.patch("blueprints_dir", TMP) def test_file_tag_applied_on_change(self): """Test blueprint is re-applied when the contents of a referenced `!File` change""" blueprint_id = generate_id() with NamedTemporaryFile(mode="w+", dir=TMP) as secret: - secret.write("initial") - secret.flush() + self.write_secret(secret, "initial") with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: file.write( f"version: 1\nentries: []\n" @@ -263,175 +322,7 @@ def test_file_tag_applied_on_change(self): instance = BlueprintInstance.objects.filter(name=blueprint_id).first() before = instance.last_applied_hash self.assertEqual(instance.status, BlueprintInstanceStatus.SUCCESSFUL) - secret.seek(0) - secret.truncate() - secret.write("rotated") - secret.flush() + self.write_secret(secret, "rotated") blueprints_discovery.send() instance.refresh_from_db() self.assertNotEqual(instance.last_applied_hash, before) - - def assert_discovery_survives(self, reference: str): - """Assert a blueprint referencing `reference` neither breaks its own hashing nor - stops a healthy blueprint alongside it from being discovered""" - healthy_id = generate_id() - with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as broken: - broken.write(f"version: 1\nentries: []\ncontext:\n secret: {reference}\n") - broken.flush() - with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as healthy: - healthy.write(f"version: 1\nentries: []\nmetadata:\n name: {healthy_id}\n") - healthy.flush() - found = [blueprint.path for blueprint in blueprints_find()] - self.assertIn(Path(healthy.name).name, found) - self.assertIn(Path(broken.name).name, found) - - @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_path_from_mapping(self): - """Test a `!File` built from a mapping node is skipped rather than raising, so - discovery of other blueprints continues""" - self.assert_discovery_survives(f'!File {{path: "{TMP}/fallback"}}') - - @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_path_unopenable(self): - """Test a `!File` whose path cannot be opened by any syscall is skipped rather - than raising, so discovery of other blueprints continues""" - self.assert_discovery_survives('!File "\\0"') - - @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_path_from_mapping_stable(self): - """Test the hash of a `!File` built from a mapping node is stable""" - with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: - reference = f'!File {{path: "{TMP}/fallback"}}' - self.assertEqual( - self.write_blueprint(file, reference), - self.write_blueprint(file, reference), - ) - - @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_path_unopenable_stable(self): - """Test the hash of a `!File` with an unopenable path is stable""" - with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: - reference = '!File "\\0"' - self.assertEqual( - self.write_blueprint(file, reference), - self.write_blueprint(file, reference), - ) - - @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_cycle_content_changed(self): - """Test hash changes when a `!File` reached through a cyclic anchor changes""" - with NamedTemporaryFile(mode="w+", dir=TMP) as secret: - secret.write("initial") - secret.flush() - with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: - cycle = f"&anchor [*anchor, !File {secret.name}]" - before = self.write_blueprint(file, cycle) - secret.seek(0) - secret.truncate() - secret.write("rotated") - secret.flush() - after = self.write_blueprint(file, cycle) - self.assertNotEqual(before, after) - - @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_alias_content_changed(self): - """Test hash changes when a `!File` reachable only through an alias changes""" - with NamedTemporaryFile(mode="w+", dir=TMP) as secret: - secret.write("initial") - secret.flush() - with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: - alias = f"&anchor [!File {secret.name}]\n other: *anchor" - before = self.write_blueprint(file, alias) - secret.seek(0) - secret.truncate() - secret.write("rotated") - secret.flush() - after = self.write_blueprint(file, alias) - self.assertNotEqual(before, after) - - @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_cycle_sequence(self): - """Test a blueprint whose anchor makes a sequence contain itself is hashed - rather than raising, so discovery of other blueprints continues""" - self.assert_discovery_survives("&anchor [*anchor]") - - @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_cycle_mapping(self): - """Test a blueprint whose anchor makes a mapping contain itself is hashed - rather than raising, so discovery of other blueprints continues""" - self.assert_discovery_survives("&anchor {key: *anchor}") - - @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_deeply_nested(self): - """Test a deeply nested blueprint with no cycle is hashed""" - with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: - reference = "[" * 50 + f'!File "{TMP}/fallback"' + "]" * 50 - self.assertEqual( - self.write_blueprint(file, reference), - self.write_blueprint(file, reference), - ) - - @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_alias_hashed_per_route(self): - """Test a `!File` reachable by two routes through one anchor is folded into the - hash once per route, as it is when the tag is simply written out twice""" - with NamedTemporaryFile(mode="w+", dir=TMP) as secret: - secret.write("initial") - secret.flush() - with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: - alias = f"&anchor [!File {secret.name}]\n other: *anchor" - content = f"version: 1\nentries: []\ncontext:\n secret: {alias}\n" - expected = sha512(content.encode()) - for _ in range(2): - expected.update(sha512(b"initial").digest()) - self.assertEqual(self.write_blueprint(file, alias), expected.hexdigest()) - - @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_removed(self): - """Test hash changes when a referenced `!File` that existed disappears""" - with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: - secret_path = Path(TMP) / generate_id() - secret_path.write_text("present") - reference = f"!File {secret_path}" - try: - before = self.write_blueprint(file, reference) - finally: - secret_path.unlink() - after = self.write_blueprint(file, reference) - self.assertNotEqual(before, after) - - @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_contents_swapped(self): - """Test hash changes when two referenced `!File`s exchange their contents""" - with ( - NamedTemporaryFile(mode="w+", dir=TMP) as first, - NamedTemporaryFile(mode="w+", dir=TMP) as second, - ): - first.write("alpha") - first.flush() - second.write("beta") - second.flush() - with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: - reference = f"[!File {first.name}, !File {second.name}]" - before = self.write_blueprint(file, reference) - for secret, value in ((first, "beta"), (second, "alpha")): - secret.seek(0) - secret.truncate() - secret.write(value) - secret.flush() - after = self.write_blueprint(file, reference) - self.assertNotEqual(before, after) - - @CONFIG.patch("blueprints_dir", TMP) - def test_file_tag_cycle_hashed_once(self): - """Test a `!File` beside a node that contains itself is folded into the hash - exactly once, however many times the cycle could be followed""" - with NamedTemporaryFile(mode="w+", dir=TMP) as secret: - secret.write("initial") - secret.flush() - with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file: - cycle = f"&anchor [*anchor, !File {secret.name}]" - content = f"version: 1\nentries: []\ncontext:\n secret: {cycle}\n" - expected = sha512(content.encode()) - expected.update(sha512(b"initial").digest()) - self.assertEqual(self.write_blueprint(file, cycle), expected.hexdigest()) From 2453510bcf58f8823516d41c4eaf68ebff6f753f Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:57:21 +0000 Subject: [PATCH 09/12] blueprints: pin discovery surviving a two-anchor cycle and an unencodable `!File` path --- authentik/blueprints/tests/test_v1_tasks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/authentik/blueprints/tests/test_v1_tasks.py b/authentik/blueprints/tests/test_v1_tasks.py index 7ec7b35a6239..9c90c94d929c 100644 --- a/authentik/blueprints/tests/test_v1_tasks.py +++ b/authentik/blueprints/tests/test_v1_tasks.py @@ -289,8 +289,10 @@ def test_file_tag_unreadable_discovery_continues(self): for label, reference in ( ("path from a mapping", f'!File {{path: "{TMP}/fallback"}}'), ("path no syscall can take", '!File "\\0"'), + ("path outside the filesystem encoding", '!File "\\ud800"'), ("sequence containing itself", "&anchor [*anchor]"), ("mapping containing itself", "&anchor {key: *anchor}"), + ("two anchors containing each other", "&outer [{inner: &inner [*outer]}, *inner]"), ): with ( self.subTest(label), From a3c2c6e817fea4c15dfc55a13898f3453818169a Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:47:39 +0000 Subject: [PATCH 10/12] blueprints: trim `!File` hashing comments to the runtime contract --- authentik/blueprints/v1/tasks.py | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/authentik/blueprints/v1/tasks.py b/authentik/blueprints/v1/tasks.py index f1882ce3dd95..20a7c9b77332 100644 --- a/authentik/blueprints/v1/tasks.py +++ b/authentik/blueprints/v1/tasks.py @@ -66,10 +66,8 @@ class BlueprintFile: 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. Anchors and aliases let a node contain itself, so a node already on - the path from the root is not descended into again; a node shared by two disjoint - routes is not its own ancestor and is still walked from each of them, as it is - without this check.""" + 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)} @@ -88,34 +86,23 @@ def iter_file_tags(value: Any, ancestors: frozenset[int] = frozenset()) -> Gener def blueprint_hash(content: str) -> str: - """Hash a blueprint's content, including the contents of the files it references with - `!File` tags. Those files are not part of the blueprint itself, so hashing the content - alone means a changed file (such as a rotated secret mounted into the container) is - never detected as a change and the blueprint is never re-applied.""" + """Hash a blueprint's content and the contents of the files it references with + `!File` tags""" hasher = sha512(content.encode()) try: raw_blueprint = load(content, BlueprintLoader) except YAMLError: return hasher.hexdigest() for tag in iter_file_tags(raw_blueprint): - # `File.__init__` assigns `path` only for scalar and sequence nodes, so a `!File` - # built from any other node has no `path` attribute at all, and a path taken from - # a nested tag is a tag rather than a string. Neither can be read without an entry - # and a blueprint. Hashing must never fail on a blueprint that can be loaded, so - # skip them; the tag's own content is part of the content hashed above. Read the - # attribute defensively - the check itself must not be what raises. + # 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: - # The file can't be read - `ValueError` for a path no syscall can take, such - # as one containing a null byte - so the tag resolves to its default value, - # which is part of the content hashed above + # Unreadable references contribute only their blueprint source text continue - # Only the referenced contents need digesting; the path itself is a substring of - # the content already hashed above hasher.update(sha512(referenced).digest()) return hasher.hexdigest() From 18479bddc34eabab26995a951d5467d749339d9f Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:47:39 +0000 Subject: [PATCH 11/12] blueprints: test CRLF blueprint discovery and apply hashes agree --- authentik/blueprints/tests/test_v1_tasks.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/authentik/blueprints/tests/test_v1_tasks.py b/authentik/blueprints/tests/test_v1_tasks.py index 9c90c94d929c..7fb834d39afa 100644 --- a/authentik/blueprints/tests/test_v1_tasks.py +++ b/authentik/blueprints/tests/test_v1_tasks.py @@ -158,6 +158,26 @@ def test_valid_disabled(self): BlueprintInstanceStatus.UNKNOWN, ) + @CONFIG.patch("blueprints_dir", TMP) + def test_valid_crlf(self): + """Test discovered hash matches the applied hash for a file with CRLF line endings""" + blueprint_id = generate_id() + with NamedTemporaryFile(suffix=".yaml", dir=TMP) as file: + file.write( + f"version: 1\r\nentries: []\r\nmetadata:\r\n name: {blueprint_id}\r\n".encode() + ) + file.flush() + for _ in range(2): + blueprints_discovery.send() + instance = BlueprintInstance.objects.filter(name=blueprint_id).first() + self.assertEqual(instance.status, BlueprintInstanceStatus.SUCCESSFUL) + found = next( + found for found in blueprints_find() if found.path == Path(file.name).name + ) + self.assertEqual(instance.last_applied_hash, found.hash) + file.seek(0) + self.assertIn(b"\r\n", file.read()) + def write_blueprint(self, file, value: str): file.seek(0) file.truncate() From e518be0aab1737e609e15735e43d6640bfa98ad1 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:45:49 +0000 Subject: [PATCH 12/12] blueprints: pin the CRLF blueprint's discovery hash to its text-mode digest --- authentik/blueprints/tests/test_v1_tasks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/authentik/blueprints/tests/test_v1_tasks.py b/authentik/blueprints/tests/test_v1_tasks.py index 7fb834d39afa..902a4880bea5 100644 --- a/authentik/blueprints/tests/test_v1_tasks.py +++ b/authentik/blueprints/tests/test_v1_tasks.py @@ -167,6 +167,7 @@ def test_valid_crlf(self): f"version: 1\r\nentries: []\r\nmetadata:\r\n name: {blueprint_id}\r\n".encode() ) file.flush() + file_hash = sha512(Path(file.name).read_text(encoding="utf-8").encode()).hexdigest() for _ in range(2): blueprints_discovery.send() instance = BlueprintInstance.objects.filter(name=blueprint_id).first() @@ -174,6 +175,7 @@ def test_valid_crlf(self): found = next( found for found in blueprints_find() if found.path == Path(file.name).name ) + self.assertEqual(found.hash, file_hash) self.assertEqual(instance.last_applied_hash, found.hash) file.seek(0) self.assertIn(b"\r\n", file.read())