feat(identity): pluggable auth verifier seam for OAuth/OIDC portability - #232
Merged
Conversation
The identity check was hard-wired to ONE scheme: sha256 the bearer, match `token_sha`. That is a
bearer-SECRET scheme — govd must have seen the secret to know the sha. An external IdP answers the same
question ("which principal is this?") without govd holding a secret at all.
Both are the same shape — bearer -> SUBJECT -> principal_id — so the seam is a pluggable subject resolver.
Everything downstream is untouched: acl_allows, rate buckets and the provenance `principal` already key off
a principal id and never off a token.
principals.resolve_principal(bearer, registry, verifier) # THE single identity entry point
principals.register_verifier(name, fn) # fn(bearer) -> subject | None
cfg["auth_verifier"] / GOVD_AUTH_VERIFIER # "" | "token_sha" = built-in (default)
A principal opts in by declaring `subject` (e.g. "oidc:sub:1a2b3c"). govd + fleetd both route through it.
Every branch fails CLOSED, each pinned by a test:
- an UNKNOWN verifier resolves nobody — it must never fall back to the secret path, or a typo in
auth_verifier silently re-enables bearer secrets on a deployment that meant to disable them;
- a raising verifier (JWKS unreachable) is a refusal, not an allow;
- an empty/None subject never matches, so a principal that has not opted in stays unreachable;
- a bearer SECRET does not authenticate under an external scheme.
DELIBERATELY NOT DONE: no JWT parsing, no JWKS fetch, no network. A verifier that reaches the network inside
the syscall boundary is a new failure+latency mode on every claim and belongs behind an operator-configured
cache. This is the seam only; the default path is behaviourally identical.
Verified: 245 local tests (principals, govd, fleetd, acl, aclverify, exod_acl, skillacl, fleetdash,
govd_delegated). Governed gate — cws-pm over 16 validators incl. grant forge/replay/expiry/wrong-run/
capability — 16/16 before (4abf09645e6f40ff) and after (5600baac366f4287); NOTE those run against the
image's /app copy, so they attest the gate did not regress rather than exercising this change.
Ships the design notes this came out of: the delegated execution ledger (exod-sealed records, origin-routed
recipients, request+accept handover) and the identity/approval playbook.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rhCat
added a commit
that referenced
this pull request
Jul 26, 2026
…he auth seam (#234) The first verifier for the seam merged in #232, and deliberately not an IdP one. WHY NOT OIDC FIRST. An OIDC verifier checks a token offline, but *obtaining* one needs the internet and the IdP up — and shortening token lifetimes to tighten revocation makes that dependency worse, so the two cannot be tuned together. This fleet is tailnet-scoped and dependency-free on purpose (tailnet-only binds, deny-by-default ACLs, --unshare-net on confined steps, "tailnet = reachability, not authorization"); an external IdP would be the first service the governance kernel needs UP to function, and a third party could lock the operator out of approving actions on their own fleet. This has no issuer, no JWKS, and no network in either direction. THE SHAPE. The bearer is a self-contained short-lived ASSERTION: a DSSE envelope — the same infra.cwp.sign surface that already signs grants and exod results, no new crypto — over {pub, iat, exp, nonce}, base64url'd to fit an Authorization header. The assertion carries its own public key, the signature is checked against THAT key, and the verifier returns sign.keyid(pub) = "ed25519:<16 hex>" as the subject. That is not circular, and the distinction is the whole design: anyone can mint a valid assertion with a key they generated and it WILL verify. What they cannot do is make it resolve to a principal — resolve_principal matches a subject only against one a registry entry DECLARES. So the signature proves possession of a key; the mounted principals.json decides whether that key is anybody. Revocation is deleting a line from a file already bind-mounted read-only into every node: instant, offline, no expiry window to wait out, no issuer to consult. Fail-closed everywhere, 21 tests, each asserting a refusal: - a VALID assertion from an undeclared key resolves to nobody (the property that matters most); - expired, future-dated (a lying clock cannot post-date its way to a long credential), and a hand-rolled assertion claiming a year-long TTL — the verifier does not trust the minter's clamp; - tampered payload (swapping the embedded pubkey breaks the signature — it is self-verifying); - a pubkey that is not 32 bytes; malformed/undecodable bearers; a bearer SECRET under this scheme; - REPLAY: a spent nonce is refused inside its own validity window, and when the bounded cache is full of LIVE nonces it refuses rather than evicting one and permitting a replay. Mutation-verified: disabling the replay check fails 2 tests; disabling signature verification fails the tamper test. The verifier never raises and never reveals WHY it refused — a caller that could tell "bad signature" from "expired" from "replayed" is an oracle, and govd answers 401 either way. Governed gate (cws-pm, 16 validators incl. grant forge/replay/expiry/wrong-run/capability): 16/16 (5d8cb29162d34fa4). Local: ed25519_auth, principals, govd, fleetd, acl all green. NEXT (not this PR): the OIDC overlay sits ABOVE this, never beside it — authenticate to an IdP, receive a short-lived Ed25519 certificate signed by an operator CA, verified offline against a pinned CA pubkey. The SSH-CA / Sigstore pattern. That moves the online dependency from every claim to periodic enrollment, and degrades correctly: without the overlay this half is a complete working system, enrolled by hand. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
rhCat
added a commit
that referenced
this pull request
Jul 26, 2026
…isters nothing (#235) * fix(identity): actually WIRE the built-in verifier — a config key registers nothing #234 shipped the Ed25519 verifier and #232 the seam, and between them the feature was unusable: nothing ever called register_verifier. Setting auth_verifier="ed25519" made every claim fail closed — correct behaviour, and a silently dead feature. Found by standing a real govd up from merged main with auth_verifier="ed25519" and watching a valid assertion resolve to nobody. No unit test could have caught it: both halves were individually correct. serve() now calls install_builtin_verifier(cfg) before the first claim. Only a name we SHIP is installed; an unknown name installs nothing and therefore resolves nobody, which is the intended fail-closed behaviour for a typo — it must never fall back to the bearer-secret path. Verifier CODE stays in the image by design: a verifier loaded from the mounted config would be ungoverned code executing inside the syscall boundary, which is the one thing this system exists to prevent. The mount configures WHICH verifier, never its body. Verified against a real govd over real HTTP (local, merged main, ed25519 scheme): alice's assertion -> 200 allow, and the signed chain records principal=alice undeclared key -> 401 bearer secret -> 401 no credential -> 401 That `principal=alice` is the point of the whole exercise: a named identity in the audit trail, which a shared per-node token can never provide. TESTS. Two unit tests pin the helper, and — because mutation showed deleting the serve() call left every unit test GREEN — an integration test starts a real govd on a free port and authenticates over HTTP, so the CALL SITE is covered, not just the function. Re-mutated after adding it: the suite now goes red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(govd): cover install_builtin_verifier in govd.py's RATCHET SLICE CI's enforcement-surface mutation ratchet caught this and it was right to: [FAIL] infra/govern/govd.py: score=0.95 floor=1.0 survivors=['==->!=@9986'] MUTATION REGRESSION below floor: [('infra/govern/govd.py', 0.95, 1.0)] govd.py was at a perfect 1.0 — every mutant killed — and the new `if name == "ed25519"` branch dropped it to 0.95. The branch was NOT untested: tests/test_ed25519_auth.py kills that mutant. But the ratchet drives each enforcement-surface module against a DESIGNATED slice (infra/govern/selfmonitor_policy.json: govd.py -> tests/test_govd.py), so a proof living anywhere else is invisible to it. Coverage for code in govd.py belongs in govd.py's slice; that policy is the point, not an obstacle to route around — the alternative (widening the slice) would dilute the ratchet for every module. Verified by re-running the exact CI survivor locally: mutating `==` to `!=` now fails tests/test_govd.py::test_install_builtin_verifier_registers_only_a_name_we_ship, and the suite is green restored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Identity was hard-wired to one scheme: sha256 the bearer, match
token_sha. That is a bearer-secret scheme — govd must have seen the secret to know the sha. An external IdP answers the same question without govd holding a secret at all.Both are the same shape —
bearer → subject → principal_id— so the seam is a pluggable subject resolver. Everything downstream is untouched:acl_allows, rate buckets and the provenanceprincipalalready key off a principal id, never a token.A principal opts in by declaring
subject(e.g."oidc:sub:1a2b3c"). govd and fleetd both route through it.Every branch fails closed, each pinned by a test
auth_verifiersilently re-enables bearer secrets on a deployment that meant to disable themDeliberately not done
No JWT parsing, no JWKS fetch, no network. A verifier that reaches the network inside the syscall boundary is a new failure and latency mode on every claim, and belongs behind an operator-configured cache. This is the seam only; the default path is behaviourally identical.
Verification
245 local tests (principals, govd, fleetd, acl, aclverify, exod_acl, skillacl, fleetdash, govd_delegated).
Governed gate —
cws-pmover 16 validators incl. grant forge / replay / expiry / wrong-run / capability — 16/16 before (4abf09645e6f40ff) and after (5600baac366f4287). Note those run against the image's/appcopy, so they attest the gate did not regress rather than exercising this change.Also ships the design notes this came out of: the delegated execution ledger (exod-sealed records, origin-routed recipients, request+accept handover) and the identity/approval playbook.
Deploy
Runs inside the container — needs a body-image rebuild to become available. Inert until then, since the default path is unchanged.
🤖 Generated with Claude Code