Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions authentik/blueprints/tests/test_v1_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]"),
Expand Down
67 changes: 43 additions & 24 deletions authentik/blueprints/v1/hash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
2 changes: 1 addition & 1 deletion website/docs/customize/blueprints/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading