Skip to content

blueprints: re-apply when a file referenced by !File changes - #26402

Merged
BeryJu merged 15 commits into
goauthentik:mainfrom
sprayberry-code:fix/blueprint-file-tag-hash
Oct 15, 2026
Merged

BeryJu merged 15 commits into
goauthentik:mainfrom
sprayberry-code:fix/blueprint-file-tag-hash

Conversation

@askalf

@askalf askalf commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • A blueprint's change-detection hash covered only the blueprint .yaml file, not the files it references through !File tags. Rotating a Kubernetes Secret mounted into the container therefore left the hash identical, so check_blueprint_v1_file never dispatched apply_blueprint and authentik kept serving the old value.
  • Adds blueprint_hash(content) in authentik/blueprints/v1/tasks.py: the blueprint's own content, then the contents of every file it references through a !File tag, each folded in as a fixed-length sha512 digest.
  • Adds iter_file_tags(value, ancestors), which walks the loaded blueprint and finds !File tags including ones passed as arguments to another tag (!Format ["client-%s", !File /x]) — a flat scan of the top level would miss those. The walk skips a node already on the path from the root, because YAML anchors let a node contain itself; a node merely shared by two routes is not its own ancestor and is still walked from each, so no existing blueprint's digest moves.
  • Skips any !File whose path is not a usable string, reading the attribute with getattr(tag, "path", None). File.__init__ assigns self.path only for scalar and sequence nodes, so a !File built from a mapping node has no path attribute at all, and a sequence-node path built from a nested tag (!File [!Env P, default]) is a tag object. Resolving those needs an entry and a blueprint the hashing path does not have, and their own content is already in the digest.
  • Both places that compute the hash call it (blueprints_find, which produces the value compared against the DB, and apply_blueprint, which stores it). They must agree or every discovery run would re-apply forever; that symmetry is why this is one shared helper.
  • A blueprint with no !File tag hashes exactly as before when it has LF line endings, so the two literal sha512 digests pinned by the pre-existing tests do not move.
  • One deliberate side effect, now pinned by test_valid_crlf: discovery used to hash the raw file bytes while apply_blueprint hashed the text-mode read that retrieve_file() returns, so a blueprint saved with CRLF line endings never matched its own last_applied_hash and was re-applied on every discovery run. Both sides now hash the same text, so they agree.

Head of this branch: e518be0aab1737e609e15735e43d6640bfa98ad1. The production file authentik/blueprints/v1/tasks.py is byte-identical from 1c67effb1 through 3ad347d1b, e0bdb02a4, c4c91590a and 2453510bc; the tenth commit a3c2c6e81 changes only its comments and docstrings (the module's AST with docstrings blanked is identical before and after, and every statement is unchanged), the eleventh 18479bddc is a test, and the twelfth e518be0aa (a fifth verification round) adds one expected-value assertion to that test. Transcripts below that name 3ad347d1b, e0bdb02a4 or c4c91590a were run against that identical source and are labelled with the sha they were captured at. The probe transcripts in ## Repro and ## Test evidence were re-run at this head. The issue's own scenario, both arms, run at this head:

$ python repro.py /agent-workspace/oss/authentik-base-arm   # base 38fca6b34, no fix
# arm: base (/agent-workspace/oss/authentik-base-arm)
FAIL: test_file_tag_content_changed - 0904e2ed0ad1aaad vs 0904e2ed0ad1aaad
FAIL: test_file_tag_content_changed_nested - f76c17ed9decdfa2 vs f76c17ed9decdfa2
FAIL: test_file_tag_created - 84daa043565dac0a vs 84daa043565dac0a
PASS: test_file_tag_content_unchanged (control)
PASS: test_file_tag_missing (control)
PASS: test_file_tag_path_from_tag (control)
PASS: plain blueprint hash unchanged (control)
PASS: invalid yaml hash unchanged (control)

5 passed, 3 failed

$ python repro.py /agent-workspace/oss/authentik-wt-verify3   # e0bdb02a4; same result re-run at e518be0aa
# arm: fixed (/agent-workspace/oss/authentik-wt-verify3)
PASS: test_file_tag_content_changed - 9f47abbce2e09297 vs ed1236ca39d98b53
PASS: test_file_tag_content_changed_nested - 6e48d3a19656a19d vs 3b2a1da981850ae7
PASS: test_file_tag_created - 9fd1cd0d5ee854b5 vs 8c67ee9d122c7e9c
PASS: test_file_tag_content_unchanged (control)
PASS: test_file_tag_missing (control)
PASS: test_file_tag_path_from_tag (control)
PASS: plain blueprint hash unchanged (control)
PASS: invalid yaml hash unchanged (control)

8 passed, 0 failed

On base the three discriminating cases print the same digest twice — that identity is the bug.

Commits three to five on this branch answer two rounds of adversarial verification, the seventh (e0bdb02a4) adds the three tests a third round asked for, the eighth (c4c91590a) folds the accumulated tests into the surrounding file's idiom at a gating review's request, the ninth (2453510bc) adds the two discovery inputs a fourth verification round found unpinned, the tenth and eleventh (a3c2c6e81, 18479bddc) answer a second-opinion review: comments trimmed to the runtime contract, and a CRLF discovery/apply test, and the twelfth (e518be0aa) pins that test's discovery hash to an expected digest so it no longer asserts agreement alone (round one, round two); see ## Rework at the end of this body for exactly what changed in each and why one line of the fix was deleted rather than tested.

Decisions

  • Include referenced !File contents in the change-detection hash while preserving the content-only digest for blueprints without file tags.
  • Walk nested tag arguments and guard cyclic YAML anchors by ancestor path; preserve shared-subtree traversal semantics.
  • Skip unresolvable file-tag paths so discovery remains resilient, matching the prior behavior of hashing the blueprint source.

All targeted verification described above was run; the upstream full CI matrix was not run locally because it requires the project's full containerized environment.

AI assistance: this bug was found and the fix and tests were drafted with AI tooling in my workflow; the tests and checks above were executed as pasted. I'm responsible for the change and will handle review feedback.

closes #26289

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 goauthentik#26289
`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.
…overy

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.
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.
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.
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.
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.
@askalf
askalf requested a review from a team as a code owner September 24, 2026 01:27
@netlify

netlify Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for authentik-integrations ready!

Name Link
🔨 Latest commit 3d788d4
🔍 Latest deploy log https://app.netlify.com/projects/authentik-integrations/deploys/6ac3ea91ef5eb0000870ff58
😎 Deploy Preview https://deploy-preview-26402--authentik-integrations.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for authentik-docs ready!

Name Link
🔨 Latest commit 3d788d4
🔍 Latest deploy log https://app.netlify.com/projects/authentik-docs/deploys/6ac3ea91a994610008d7cb7c
😎 Deploy Preview https://deploy-preview-26402--authentik-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@codecov

codecov Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.88268% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.34%. Comparing base (1495c1d) to head (07efd06).
⚠️ Report is 55 commits behind head on main.

Files with missing lines Patch % Lines
authentik/blueprints/v1/hash.py 95.00% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #26402    +/-   ##
========================================
  Coverage   92.33%   92.34%            
========================================
  Files        1175     1178     +3     
  Lines       77789    78262   +473     
  Branches     4129     4129            
========================================
+ Hits        71827    72269   +442     
- Misses       5896     5927    +31     
  Partials       66       66            
Flag Coverage Δ
conformance 32.74% <6.14%> (-0.18%) ⬇️
e2e 37.49% <6.14%> (-0.23%) ⬇️
integration 29.08% <6.14%> (-0.63%) ⬇️
rust 46.45% <ø> (ø)
unit 93.79% <98.88%> (+0.03%) ⬆️
unit-migrate 93.78% <98.88%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

blueprint_hash skipped an unreadable reference entirely, so [missing, X] and
[X, missing] both hashed as the content plus the digest of X: a file
disappearing while another appeared with the same contents left the hash
unchanged, and discovery would not re-apply the blueprint. An unreadable
reference now contributes a one-byte marker, where a readable one
contributes its 64-byte digest, so its position counts. Blueprints whose
references all resolve hash as before.
@netlify

netlify Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for authentik-storybook ready!

Name Link
🔨 Latest commit 3d788d4
🔍 Latest deploy log https://app.netlify.com/projects/authentik-storybook/deploys/6ac3ea91a994610008d7cb78
😎 Deploy Preview https://deploy-preview-26402--authentik-storybook.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@BeryJu

BeryJu commented Oct 8, 2026

Copy link
Copy Markdown
Member

@askalf Could you move this new hashing logic to its own file, blueprints/v1/hash.py? I tried to make this change but wasn't able to push to your fork

@BeryJu BeryJu added the status/awaiting-contributor Awaiting a response from the contributor label Oct 8, 2026
@askalf

askalf commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Moved to authentik/blueprints/v1/hash.py in 07efd06: iter_file_tags, MISSING_FILE_MARKER and blueprint_hash are there unchanged, and tasks.py imports blueprint_hash from it for the two call sites in blueprints_find and apply_blueprint. The tests in test_v1_tasks.py go through blueprints_find and apply_blueprint, so they did not need to change.

@BeryJu
BeryJu merged commit 4c31049 into goauthentik:main Oct 15, 2026
139 of 140 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:backend status/awaiting-contributor Awaiting a response from the contributor

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Blueprints do not re-apply when a referenced Kubernetes Secret changes

3 participants