fix(keycloak-passkey): bind only on git's say-so; protect real sub-flows; screen the requirement PUT by body id
Review findings on the passkey reconciler, fixed:
1. HIGH - the bind was automatic and rollback did not stick. A git-declared switch,
`bind-browser-flow` in passkey-desired-state.yaml, now decides whether pass 3 may
PUT browserFlow. Default "false": build + verify the candidate (or reuse a verified
one), log WOULD BIND <alias>, never touch the binding - including after an earlier
bind, so an operator's rollback sticks, and a hand-edited bound candidate is left
alone rather than shadowed by an -r2 build. "true" binds as before. The renderer
carries the one hand-edited line through and --check refuses anything but the
quoted strings "true"/"false". --no-bind can only narrow it.
2. HIGH - the protected-subtree guard read `alias` on sub-flow rows. Keycloak 26.0.6
AuthenticationManagementResource.recurseExecutions sets displayName =
flowRef.getAlias() and flowId on a sub-flow row and never sets alias (alias is the
CONFIG alias on authenticator rows), so the protected set was only the top-level
alias. Sub-flows are now named by displayName (getFlows() lists top-level flows
only, so flowId cannot be resolved through it); a sub-flow row without one leaves
the subtree undetermined and the flow pass fail-closed. The selftest stub now
returns 26.0.6-shaped rows, and a real run is asserted to refuse writes into
browser-mfa-forms and browser-mfa-conditional-otp.
3. MEDIUM - PUT /flows/{alias}/executions was screened on the path alias only, but
updateExecutions resolves the execution realm-wide by body id. body.id is now
screened against the protected ids AND required to be in the tree this run is
building (recorded from the build's own readback); the config POST is held to the
same build scope; and the body may carry only id/requirement/priority (the same
handler renames any flow named by flowId).
4. Stale "audit-only" docs rewritten (platform-config kustomization, chainsaw test,
desired-state header and its renderer template, README) to describe what ships.
5. backoffLimit 0 (a retried deterministic build failure burns the candidate-alias
budget); the stale "Renovate does not manage image: tags" comment corrected and the
image pinned by index digest.
6. The ~160-line header duplicated in the YAML and the script is now kept once, in the
script, with a short pointer above it.
Selftest: 259 assertions (was 216). mutations.py: 40 injected regressions (was 30),
including one per finding above, all caught.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f5931944fa
commit
17eed6e8ae
8 files changed
+893
-310
No files matched your search
@@ -18,7 +18,18 @@
|
||||
# keycloak-realm-import.yaml declares are INERT. They are in git, Flux applied
|
||||
# them, and they reach the realm never. platform/keycloak/passkey-reconciler.yaml
|
||||
# is the level-triggered loop that closes that; this renderer supplies the DESIRED
|
||||
# STATE it converges towards (and, for the flow, audits against).
|
||||
# STATE it converges towards.
|
||||
#
|
||||
# THE ONE VALUE THAT IS NOT RENDERED: `bind-browser-flow`, git's switch for whether the
|
||||
# reconciler may BIND its verified candidate browser flow (one atomic PUT of the
|
||||
# realm's browserFlow). It is an operator DECISION -- "I have tested the candidate
|
||||
# flow in a browser; make it the login path" -- not a fact derivable from the realm
|
||||
# import, so it cannot be generated. It is instead CARRIED THROUGH from the committed
|
||||
# file: --write preserves it, --check accepts any value of it that is exactly the
|
||||
# string "true" or "false" and rejects everything else (an unquoted YAML true is a
|
||||
# bool, which a ConfigMap rejects at apply time -- failing the whole platform-config
|
||||
# Kustomization -- so it is refused HERE, at merge time). An absent file or key
|
||||
# renders as "false". Going live is therefore a one-line PR flipping that line.
|
||||
#
|
||||
# THE CONSTRAINT THAT SHAPES IT: the reconciler pod runs with
|
||||
# automountServiceAccountToken: false, exactly like its client-secret sibling. It
|
||||
@@ -148,6 +159,33 @@ PASSKEY_AUTHENTICATOR = "webauthn-authenticator-passwordless"
|
||||
ENROL_ACTION = "webauthn-register-passwordless"
|
||||
|
||||
|
||||
BIND_KEY = "bind-browser-flow"
|
||||
BIND_VALUES = ("true", "false")
|
||||
|
||||
|
||||
def committed_bind(root: Path) -> str:
|
||||
"""The operator's bind-browser-flow value from the COMMITTED file, or "false".
|
||||
|
||||
This is the only input read from the output file, and it is validated, not trusted:
|
||||
anything other than the exact strings "true" / "false" is a hard failure."""
|
||||
target = root / OUT_FILE
|
||||
if not target.is_file():
|
||||
return "false"
|
||||
docs = [d for d in yaml.safe_load_all(target.read_text(encoding="utf-8")) if d]
|
||||
cm = next((d for d in docs if d.get("kind") == "ConfigMap"), None) or {}
|
||||
data = cm.get("data") or {}
|
||||
if BIND_KEY not in data:
|
||||
return "false"
|
||||
v = data[BIND_KEY]
|
||||
if not isinstance(v, str) or v not in BIND_VALUES:
|
||||
raise SystemExit(
|
||||
"FATAL: %s data.%s is %r. It must be exactly the QUOTED string \"true\" or "
|
||||
"\"false\": an unquoted true/false is a YAML boolean, which a ConfigMap rejects "
|
||||
"at apply time and which would fail the whole platform-config Kustomization"
|
||||
% (OUT_FILE, BIND_KEY, v))
|
||||
return v
|
||||
|
||||
|
||||
def render(root: Path) -> str:
|
||||
"""Return the full text of passkey-desired-state.yaml as it should be committed."""
|
||||
docs = list(yaml.safe_load_all((root / IMPORT_FILE).read_text()))
|
||||
@@ -323,12 +361,13 @@ def render(root: Path) -> str:
|
||||
|
||||
body = json.dumps(want, indent=2, sort_keys=True, ensure_ascii=False)
|
||||
indented = "\n".join((" " + ln) if ln else "" for ln in body.splitlines())
|
||||
return HEADER + indented + "\n"
|
||||
return (HEADER + BIND_BLOCK % (BIND_KEY, committed_bind(root))
|
||||
+ " %s: |\n" % DATA_KEY + indented + "\n")
|
||||
|
||||
|
||||
HEADER = """---
|
||||
# =============================================================================
|
||||
# GENERATED FILE — DO NOT EDIT BY HAND.
|
||||
# GENERATED FILE — DO NOT EDIT BY HAND, except the one bind-browser-flow line below.
|
||||
#
|
||||
# source: platform/keycloak/keycloak-realm-import.yaml (spec.realm)
|
||||
# regenerate: python3 ci/render-keycloak-passkey-policy.py --write
|
||||
@@ -338,8 +377,14 @@ HEADER = """---
|
||||
# WHAT THIS IS: the DESIRED STATE for the `homelab` realm's PASSKEY configuration —
|
||||
# the WebAuthn passwordless policy, the enrolment required action, and the declared
|
||||
# browser authentication flow tree. platform/keycloak/passkey-reconciler.yaml mounts
|
||||
# it and (a) converges the policy and the required action onto it, (b) AUDITS the
|
||||
# live browser flow against it and alarms on divergence without rewriting it.
|
||||
# it and (a) converges the policy and the required action onto it, (b) builds the
|
||||
# declared flow tree BLUE/GREEN under a new alias and verifies it by readback, never
|
||||
# writing the flow users are logging in through, and (c) BINDS that verified flow as
|
||||
# the realm's browserFlow ONLY when `bind-browser-flow` below is "true".
|
||||
#
|
||||
# EVERYTHING HERE IS GENERATED EXCEPT ONE LINE: `bind-browser-flow`. That is an
|
||||
# operator decision, carried through by the renderer from this committed file (see
|
||||
# ci/render-keycloak-passkey-policy.py). It defaults to "false".
|
||||
#
|
||||
# WHY IT IS A SEPARATE OBJECT: the realm import is a ONE-SHOT that never re-runs
|
||||
# once Done=True, so a realm-level field or a flow edited in git never reaches the
|
||||
@@ -379,8 +424,25 @@ metadata:
|
||||
app.kubernetes.io/name: %s
|
||||
app.kubernetes.io/part-of: keycloak
|
||||
data:
|
||||
%s: |
|
||||
""" % (CONFIGMAP_NAME, NAMESPACE, CONFIGMAP_NAME, DATA_KEY)
|
||||
""" % (CONFIGMAP_NAME, NAMESPACE, CONFIGMAP_NAME)
|
||||
|
||||
BIND_BLOCK = """ # ---------------------------------------------------------------------------
|
||||
# THE BIND SWITCH -- the ONLY hand-edited line in this file. Exactly "true" or
|
||||
# "false", QUOTED.
|
||||
#
|
||||
# "false" the reconciler builds and verifies the candidate browser flow and
|
||||
# logs `WOULD BIND <alias>`, but NEVER changes the realm's browserFlow.
|
||||
# Whatever the realm is bound to -- including a rollback -- is left alone.
|
||||
# "true" it binds the verified candidate in one atomic PUT, and keeps the
|
||||
# realm bound to it: a binding changed by hand is changed back.
|
||||
#
|
||||
# GO LIVE: test the candidate in a browser first (platform/keycloak/README.md,
|
||||
# "Passkeys"), then a PR setting this to "true".
|
||||
# ROLL BACK: a PR setting this to "false" FIRST (or the next run rebinds), then
|
||||
# one write setting browserFlow back to the previous alias from the Job log.
|
||||
# ---------------------------------------------------------------------------
|
||||
%s: "%s"
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -410,8 +472,8 @@ def main() -> int:
|
||||
print("passkey-desired-state.yaml is in sync with %s" % IMPORT_FILE)
|
||||
return 0
|
||||
print("CHECK FAIL: %s has DRIFTED from %s — the reconciler would converge the "
|
||||
"realm on a passkey policy the realm import does not declare, and audit "
|
||||
"the login flow against a tree nobody authored.\n"
|
||||
"realm on a passkey policy the realm import does not declare, and build "
|
||||
"the login flow from a tree nobody authored.\n"
|
||||
"Regenerate with: python3 ci/render-keycloak-passkey-policy.py --write\n"
|
||||
% (OUT_FILE, IMPORT_FILE), file=sys.stderr)
|
||||
for line in difflib.unified_diff(have.splitlines(True), want.splitlines(True),
|
||||
|
||||
Reference in new issue
Block a user