From feea4010ecd408540e5e740f929fde69ba608563 Mon Sep 17 00:00:00 2001 From: Dominic R Date: Wed, 14 Oct 2026 14:09:32 -0400 Subject: [PATCH 1/2] blueprints: reapply when `!Env` or `!File` values change The values are keyed with the secret key before they go into the hash, since the hash is visible in the API and a plain hash of a short secret could be guessed. Blueprints without these tags keep their current hash. Closes: #26925 --- authentik/blueprints/tests/test_v1_tasks.py | 44 +++++++++++++++ authentik/blueprints/v1/common.py | 14 ++++- authentik/blueprints/v1/tasks.py | 61 ++++++++++++++++++++- 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/authentik/blueprints/tests/test_v1_tasks.py b/authentik/blueprints/tests/test_v1_tasks.py index fe406b7aa9db..fb32b7be760a 100644 --- a/authentik/blueprints/tests/test_v1_tasks.py +++ b/authentik/blueprints/tests/test_v1_tasks.py @@ -2,6 +2,7 @@ from hashlib import sha512 from tempfile import NamedTemporaryFile, mkdtemp +from unittest.mock import patch from django.test import TransactionTestCase from yaml import dump @@ -124,6 +125,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""" diff --git a/authentik/blueprints/v1/common.py b/authentik/blueprints/v1/common.py index 51761149331a..ded2b8083c2e 100644 --- a/authentik/blueprints/v1/common.py +++ b/authentik/blueprints/v1/common.py @@ -438,8 +438,12 @@ def __init__(self, loader: BlueprintLoader, node: ScalarNode | SequenceNode) -> self.key = loader.construct_object(node.value[0]) self.default = loader.construct_object(node.value[1]) + def lookup(self) -> str | None: + """Get the environment variable's current value""" + return getenv(self.key) + def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: - if env := getenv(self.key): + if env := self.lookup(): return env if isinstance(self.default, YAMLTag): return self.default.resolve(entry, blueprint) @@ -461,10 +465,14 @@ def __init__(self, loader: BlueprintLoader, node: ScalarNode | SequenceNode) -> self.path = loader.construct_object(node.value[0]) self.default = loader.construct_object(node.value[1]) + def lookup(self) -> str: + """Get the file's current content""" + with open(self.path, encoding="utf8") as _file: + return _file.read().strip() + def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: try: - with open(self.path, encoding="utf8") as _file: - return _file.read().strip() + return self.lookup() except OSError as exc: LOGGER.warning( "Failed to read file. Falling back to default value", diff --git a/authentik/blueprints/v1/tasks.py b/authentik/blueprints/v1/tasks.py index 980495581a7d..aaf70f3c7e3a 100644 --- a/authentik/blueprints/v1/tasks.py +++ b/authentik/blueprints/v1/tasks.py @@ -1,14 +1,18 @@ """v1 blueprints tasks""" +from collections.abc import Iterator from dataclasses import asdict, dataclass, field from hashlib import sha512 +from json import dumps from pathlib import Path from sys import platform +from typing import Any from uuid import UUID from dacite.core import from_dict from django.conf import settings from django.db import DatabaseError, InternalError, ProgrammingError +from django.utils.crypto import salted_hmac from django.utils.text import slugify from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ @@ -30,7 +34,14 @@ BlueprintInstanceStatus, BlueprintRetrievalFailed, ) -from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata, EntryInvalidError +from authentik.blueprints.v1.common import ( + BlueprintLoader, + BlueprintMetadata, + EntryInvalidError, + Env, + 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 @@ -55,6 +66,48 @@ class BlueprintFile: meta: BlueprintMetadata | None = field(default=None) +def _external_values(value: Any, seen: set[int]) -> Iterator[str | None]: + """Find the current values of all `!Env` and `!File` tags in a parsed blueprint""" + if isinstance(value, dict | list | tuple | YAMLTag): + # YAML anchors can share or nest nodes + if id(value) in seen: + return + seen.add(id(value)) + if isinstance(value, Env): + yield value.lookup() if isinstance(value.key, str) else None + elif isinstance(value, File): + try: + yield value.lookup() + except OSError, TypeError, ValueError: + yield None + if isinstance(value, dict): + for key, item in value.items(): + yield from _external_values(key, seen) + yield from _external_values(item, seen) + elif isinstance(value, list | tuple): + for item in value: + yield from _external_values(item, seen) + elif isinstance(value, YAMLTag): + for item in vars(value).values(): + yield from _external_values(item, seen) + + +def blueprint_hash(content: bytes, raw_blueprint: Any) -> str: + """Hash a blueprint's content and the current values of its `!Env` and `!File` tags, + so a blueprint is reapplied when only those values change. The values are keyed with + the secret key, so the stored hash can't be used to guess them.""" + digest = sha512(content) + if values := list(_external_values(raw_blueprint, set())): + digest.update( + salted_hmac( + "goauthentik.io/blueprints/v1/external-values", + dumps(values), + algorithm="sha512", + ).digest() + ) + return digest.hexdigest() + + class BlueprintWatcherMiddleware(Middleware): def start_blueprint_watcher(self): """Start blueprint watcher""" @@ -136,7 +189,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(path.read_bytes(), raw_blueprint) 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) @@ -197,8 +250,10 @@ 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() importer = Importer.from_string(blueprint_content, instance.context) + file_hash = blueprint_hash( + blueprint_content.encode(), load(blueprint_content, BlueprintLoader) + ) if importer.blueprint.metadata: instance.metadata = asdict(importer.blueprint.metadata) valid, logs = importer.validate() From ee905375057a74c8f53ad24ee4bf615dc6de7704 Mon Sep 17 00:00:00 2001 From: Dominic R Date: Wed, 14 Oct 2026 15:35:21 -0400 Subject: [PATCH 2/2] website/docs: note that blueprints reapply when `!Env` or `!File` values change --- website/docs/customize/blueprints/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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