feat: OIDC trusted publishing — deploy from CI with no stored credential - #2173
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces OIDC trusted publishing, allowing CI runners (such as GitHub Actions) to authenticate with HarperDB without stored credentials by exchanging an OIDC identity token for a short-lived operation token. The implementation includes token verification, JWKS retrieval, claim validation, trust policy management operations, and database upgrade directives. Feedback on the changes suggests registering the in-flight promise in inFlightLoads before attaching the .finally() callback in jwks.ts to avoid potential race conditions with synchronously resolving promises.
28d0993 to
ed3a389
Compare
|
Reviewed the one new commit since the last review ( |
|
[human edit] love this 🙌 This is a higher-level review — design and direction, not a line-by-line pass; that can come separately. The hardening story holds where it counts — I traced the JWKS layer, verification, the exchange, and the wiring (alg allowlist at both decode and verify, discovery issuer echo-check, bounded fetches, rate-limited unknown-
The one structural ask: make the core issuer-agnostic, and confine GitHub to a provider profile. We want this layer to seed a newer authn core — cloud/workload identity next (K8s service accounts, GCP/Azure, SPIFFE), and eventually the oauth component consuming core's verifier instead of shipping its own. The PR is closer to that than it reads:
None of these touch the hardening. One more: agree with your own instinct on AuthAuditLog — a credential issued to an external principal belongs in the auth audit trail, so I'd pull that TODO into this PR rather than after. 🤖 Drafted by Claude on behalf of @heskew |
… log processLocalTransaction logs every operation body at INFO — a common default level — after stripping a fixed field list. `token` was not on it, so exchange_oidc_token wrote the raw CI identity JWT verbatim on every call. The log happens before the handler, so a *rejected* attempt logged an unspent, still-usable credential. Caught in review by claude[bot] on #2173. Two adjacent fields had the same gap and are fixed here too, since it is the same list and the same class of bug: - `token` also carries the login-purpose token (login, #1876). - `refresh_token` carries the 30-day credential (refresh_operation_token) — pre-existing, and the longest-lived of the three. The inline rest-destructure became `redactForOperationLog` + `UNLOGGABLE_OPERATION_FIELDS`. That is not tidying: `operationLog` is built from mainLogger at module load, so the logged body cannot be intercepted after the fact, and the existing redaction test guards on `if (info_log_stub.called)` — which is never true in the unit environment, so it has been passing vacuously. Exporting the list and the function makes the contract directly testable, and drops an eslint-disable for unused vars along the way. Five tests cover it, including one pinning each credential-bearing field in the list so a refactor cannot quietly drop one the way harper#1527 did for set_env_value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addresses @heskew's structural review on #2173. No change to the hardening; this is about where the issuer-specific parts live so the layer can seed an authn core rather than a GitHub feature. - security/oidcTrust/ -> security/authn/oidc/, and the CLI's ciIdentityToken.ts -> workloadIdentity.ts, structured as a provider list (GitHub Actions is entry one; a Kubernetes entry is available() testing for a projected token path and requestToken() reading it). - providers/githubActions.ts now owns everything GitHub-shaped: the three pin requirements, workflow_path derivation, the shared-default audience regex, and principal description. Nothing else in the module says GitHub. The ref-gate rule — the part flagged as most likely to be wrong — is right for GitHub and now cannot constrain any other issuer. - providers/generic.ts is the fallback for unregistered issuers, and is strict rather than permissive: the policy must pin `sub`. That makes Kubernetes service accounts, GCP service accounts, and SPIFFE SVIDs work with zero provider code, all of which have stable canonical subjects. GitHub needs a profile precisely because its `sub` is the one claim not to pin. - The GitHub profile default-denies `pull_request_target` unless a policy constrains event_name, closing the fork callout in #2171. A plain pull_request run from a fork already cannot mint (no id-token: write); pull_request_target can. - Replay is keyed on SHA-256 of the token rather than issuer|jti, and verifyIdentityToken no longer requires jti. Azure emits `uti` and others omit it; a replayed token is byte-identical by definition, so this is strictly more general. Hashed, so the table never holds a credential. - Exchanges now emit AuthAuditLog on success and failure, through the same stream and the same logging.auditAuthEvents switches as every other authentication event. serverHandlers already injects baseRequest for NO_AUTH_OPERATIONS, so ip/method/path are available — the TODO is gone rather than deferred. claims.ts keeps only issuer-agnostic matching and constraint-shape validation; validateTrustPolicyClaims split into that plus the profile's assertPolicyIsSpecific. Tests restructured to match: provider profiles get their own suites, claims.test.js uses a deliberately non-GitHub token, and the exchange suite gains a second issuer with no profile to prove the zero-provider- code path end to end. 438 green across the touched suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All five structural asks are in 1. Provider profile extraction. Also added 2. Generic fallback requiring 3. Replay on token hash. SHA-256 of the raw token, base64url. 4. 5. CLI provider list. AuthAuditLog — pulled in rather than deferred. You were both right that it belongs here; what I had missed is that One note on 438 tests green across the touched suites. 🤖 Addressed by Claude Code |
…tions Stacked on feat/oidc-trusted-publishing. Explores the per-policy operation scoping Kris asked about — with the constraint that makes it safe, which is the reason it is a separate PR rather than part of #2173. An OIDC trust policy may carry `operations`. The exchanged token then carries that list as a claim, and verifyPerms intersects it with the user's role. One Harper user can back several workflows, each holding a credential narrower than the user itself. It can only ever subtract. Two things make that true, and both are the whole point: 1. The check is the FIRST authorization step in verifyPerms. Both the super_user bypass and the `operations` gate-2 grant return null early, so a narrowing check after either would be bypassable by exactly the identities it most needs to constrain. Tested directly: a super_user token scoped to get_status cannot insert. 2. The scope is never merged into role.permission.operations. That field is not purely narrowing — gate 2 treats an explicit listing of an SU-only operation as a deliberate grant — so merging into it could widen instead of narrow. It travels on the user as `tokenOperations` and is intersected separately. Absent claim means today's behavior exactly, so every existing token and every unscoped policy is unaffected. Operation names are validated at write time against OPERATIONS_ENUM (groups expanded first): a typo would otherwise fail closed at request time, in CI, with nothing to point at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… log processLocalTransaction logs every operation body at INFO — a common default level — after stripping a fixed field list. `token` was not on it, so exchange_oidc_token wrote the raw CI identity JWT verbatim on every call. The log happens before the handler, so a *rejected* attempt logged an unspent, still-usable credential. Caught in review by claude[bot] on #2173. Two adjacent fields had the same gap and are fixed here too, since it is the same list and the same class of bug: - `token` also carries the login-purpose token (login, #1876). - `refresh_token` carries the 30-day credential (refresh_operation_token) — pre-existing, and the longest-lived of the three. The inline rest-destructure became `redactForOperationLog` + `UNLOGGABLE_OPERATION_FIELDS`. That is not tidying: `operationLog` is built from mainLogger at module load, so the logged body cannot be intercepted after the fact, and the existing redaction test guards on `if (info_log_stub.called)` — which is never true in the unit environment, so it has been passing vacuously. Exporting the list and the function makes the contract directly testable, and drops an eslint-disable for unused vars along the way. Five tests cover it, including one pinning each credential-bearing field in the list so a refactor cannot quietly drop one the way harper#1527 did for set_env_value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addresses @heskew's structural review on #2173. No change to the hardening; this is about where the issuer-specific parts live so the layer can seed an authn core rather than a GitHub feature. - security/oidcTrust/ -> security/authn/oidc/, and the CLI's ciIdentityToken.ts -> workloadIdentity.ts, structured as a provider list (GitHub Actions is entry one; a Kubernetes entry is available() testing for a projected token path and requestToken() reading it). - providers/githubActions.ts now owns everything GitHub-shaped: the three pin requirements, workflow_path derivation, the shared-default audience regex, and principal description. Nothing else in the module says GitHub. The ref-gate rule — the part flagged as most likely to be wrong — is right for GitHub and now cannot constrain any other issuer. - providers/generic.ts is the fallback for unregistered issuers, and is strict rather than permissive: the policy must pin `sub`. That makes Kubernetes service accounts, GCP service accounts, and SPIFFE SVIDs work with zero provider code, all of which have stable canonical subjects. GitHub needs a profile precisely because its `sub` is the one claim not to pin. - The GitHub profile default-denies `pull_request_target` unless a policy constrains event_name, closing the fork callout in #2171. A plain pull_request run from a fork already cannot mint (no id-token: write); pull_request_target can. - Replay is keyed on SHA-256 of the token rather than issuer|jti, and verifyIdentityToken no longer requires jti. Azure emits `uti` and others omit it; a replayed token is byte-identical by definition, so this is strictly more general. Hashed, so the table never holds a credential. - Exchanges now emit AuthAuditLog on success and failure, through the same stream and the same logging.auditAuthEvents switches as every other authentication event. serverHandlers already injects baseRequest for NO_AUTH_OPERATIONS, so ip/method/path are available — the TODO is gone rather than deferred. claims.ts keeps only issuer-agnostic matching and constraint-shape validation; validateTrustPolicyClaims split into that plus the profile's assertPolicyIsSpecific. Tests restructured to match: provider profiles get their own suites, claims.test.js uses a deliberately non-GitHub token, and the exchange suite gains a second issuer with no profile to prove the zero-provider- code path end to end. 438 green across the touched suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6038dad to
ec026c7
Compare
…tions Stacked on feat/oidc-trusted-publishing. Explores the per-policy operation scoping Kris asked about — with the constraint that makes it safe, which is the reason it is a separate PR rather than part of #2173. An OIDC trust policy may carry `operations`. The exchanged token then carries that list as a claim, and verifyPerms intersects it with the user's role. One Harper user can back several workflows, each holding a credential narrower than the user itself. It can only ever subtract. Two things make that true, and both are the whole point: 1. The check is the FIRST authorization step in verifyPerms. Both the super_user bypass and the `operations` gate-2 grant return null early, so a narrowing check after either would be bypassable by exactly the identities it most needs to constrain. Tested directly: a super_user token scoped to get_status cannot insert. 2. The scope is never merged into role.permission.operations. That field is not purely narrowing — gate 2 treats an explicit listing of an SU-only operation as a deliberate grant — so merging into it could widen instead of narrow. It travels on the user as `tokenOperations` and is intersected separately. Absent claim means today's behavior exactly, so every existing token and every unscoped policy is unaffected. Operation names are validated at write time against OPERATIONS_ENUM (groups expanded first): a typo would otherwise fail closed at request time, in CI, with nothing to point at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… log processLocalTransaction logs every operation body at INFO — a common default level — after stripping a fixed field list. `token` was not on it, so exchange_oidc_token wrote the raw CI identity JWT verbatim on every call. The log happens before the handler, so a *rejected* attempt logged an unspent, still-usable credential. Caught in review by claude[bot] on #2173. Two adjacent fields had the same gap and are fixed here too, since it is the same list and the same class of bug: - `token` also carries the login-purpose token (login, #1876). - `refresh_token` carries the 30-day credential (refresh_operation_token) — pre-existing, and the longest-lived of the three. The inline rest-destructure became `redactForOperationLog` + `UNLOGGABLE_OPERATION_FIELDS`. That is not tidying: `operationLog` is built from mainLogger at module load, so the logged body cannot be intercepted after the fact, and the existing redaction test guards on `if (info_log_stub.called)` — which is never true in the unit environment, so it has been passing vacuously. Exporting the list and the function makes the contract directly testable, and drops an eslint-disable for unused vars along the way. Five tests cover it, including one pinning each credential-bearing field in the list so a refactor cannot quietly drop one the way harper#1527 did for set_env_value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5493a0d to
979db78
Compare
Addresses @heskew's structural review on #2173. No change to the hardening; this is about where the issuer-specific parts live so the layer can seed an authn core rather than a GitHub feature. - security/oidcTrust/ -> security/authn/oidc/, and the CLI's ciIdentityToken.ts -> workloadIdentity.ts, structured as a provider list (GitHub Actions is entry one; a Kubernetes entry is available() testing for a projected token path and requestToken() reading it). - providers/githubActions.ts now owns everything GitHub-shaped: the three pin requirements, workflow_path derivation, the shared-default audience regex, and principal description. Nothing else in the module says GitHub. The ref-gate rule — the part flagged as most likely to be wrong — is right for GitHub and now cannot constrain any other issuer. - providers/generic.ts is the fallback for unregistered issuers, and is strict rather than permissive: the policy must pin `sub`. That makes Kubernetes service accounts, GCP service accounts, and SPIFFE SVIDs work with zero provider code, all of which have stable canonical subjects. GitHub needs a profile precisely because its `sub` is the one claim not to pin. - The GitHub profile default-denies `pull_request_target` unless a policy constrains event_name, closing the fork callout in #2171. A plain pull_request run from a fork already cannot mint (no id-token: write); pull_request_target can. - Replay is keyed on SHA-256 of the token rather than issuer|jti, and verifyIdentityToken no longer requires jti. Azure emits `uti` and others omit it; a replayed token is byte-identical by definition, so this is strictly more general. Hashed, so the table never holds a credential. - Exchanges now emit AuthAuditLog on success and failure, through the same stream and the same logging.auditAuthEvents switches as every other authentication event. serverHandlers already injects baseRequest for NO_AUTH_OPERATIONS, so ip/method/path are available — the TODO is gone rather than deferred. claims.ts keeps only issuer-agnostic matching and constraint-shape validation; validateTrustPolicyClaims split into that plus the profile's assertPolicyIsSpecific. Tests restructured to match: provider profiles get their own suites, claims.test.js uses a deliberately non-GitHub token, and the exchange suite gains a second issuer with no profile to prove the zero-provider- code path end to end. 438 green across the touched suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tions Stacked on feat/oidc-trusted-publishing. Explores the per-policy operation scoping Kris asked about — with the constraint that makes it safe, which is the reason it is a separate PR rather than part of #2173. An OIDC trust policy may carry `operations`. The exchanged token then carries that list as a claim, and verifyPerms intersects it with the user's role. One Harper user can back several workflows, each holding a credential narrower than the user itself. It can only ever subtract. Two things make that true, and both are the whole point: 1. The check is the FIRST authorization step in verifyPerms. Both the super_user bypass and the `operations` gate-2 grant return null early, so a narrowing check after either would be bypassable by exactly the identities it most needs to constrain. Tested directly: a super_user token scoped to get_status cannot insert. 2. The scope is never merged into role.permission.operations. That field is not purely narrowing — gate 2 treats an explicit listing of an SU-only operation as a deliberate grant — so merging into it could widen instead of narrow. It travels on the user as `tokenOperations` and is intersected separately. Absent claim means today's behavior exactly, so every existing token and every unscoped policy is unaffected. Operation names are validated at write time against OPERATIONS_ENUM (groups expanded first): a typo would otherwise fail closed at request time, in CI, with nothing to point at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kriszyp
left a comment
There was a problem hiding this comment.
Automated gate — not yet queued for human review.
This PR's AI review found issues, and the PR description reports no cross-model reviews.
Per team policy, a substantive PR with AI-review findings is queued for human review only after at least 2 cross-model reviews have been run, their findings addressed, and the coverage reported in the PR description (## Review coverage naming each model — see harper-engineering-guidelines).
The findings below count as one of the two: address them, run a second outside-model review, update the description, and the gate lifts automatically on the next pass.
TL;DR
This PR adds issuer-agnostic OIDC trusted publishing, trust-policy administration, GitHub Actions CLI exchange, and optional operation-scoped tokens.
Verification, caching, replay protection, upgrade handling, and focused tests are substantial.
However, the short-lived credential and least-authority guarantees have concrete bypasses, plus GitHub and token-lifetime edge cases.
These affect the feature’s core security claims; hold the merge until they are resolved.
verdict: BLOCK
merge: squash
Human-Review-Need: 4 @ 2b27671
Findings
blocker — security/authn/oidc/tokenExchange.ts:205 — the one-hour OIDC credential can mint caller-extended operation tokens, 30-day refresh tokens, and login sessions
major — utility/operation_authorization.ts:475 — token operation scope is bypassed by native Resource APIs
major — utility/operation_authorization.ts:510 — the read_only scope admits write SQL whenever the role permits it
major — security/authn/oidc/providers/githubActions.ts:53 — job_workflow_ref does not constrain the caller branch in reusable workflows
major — security/authn/oidc/identityToken.ts:101 — an absent or future iat defeats the advertised identity-token lifetime ceiling
minor — security/authn/oidc/trustPolicyOperations.ts:55 — main-thread policy validation rejects worker-registered operations it claims to support
minor — DESIGN.md:212 — the design document forbids the per-policy allowlist the PR exposes
minor — unitTests/security/tokenAuthentication.test.js:448 — four new tests violate the repository’s no-new-rewire invariant
Change tour
- The verification core validates and matches claim constraints, retrieves bounded OIDC discovery/JWKS responses with caching, verifies JWTs, and isolates issuer behavior in profiles:
security/authn/oidc/claims.ts:24-134,security/authn/oidc/jwks.ts:56-211,security/authn/oidc/identityToken.ts:64-107,security/authn/oidc/providers/generic.ts,security/authn/oidc/providers/githubActions.ts,security/authn/oidc/providers/index.ts. - Trust-policy types, CRUD operations, system schema, operation constants, and the 5.3 upgrade directive establish persistent administration:
security/authn/oidc/types.ts:13-30,security/authn/oidc/trustPolicyOperations.ts:97-198,json/systemSchema.json:476-516,utility/hdbTerms.ts:214,utility/hdbTerms.ts:352-355,upgrade/directives/5-3-0.ts:18-74,upgrade/directives/directivesController.ts. - Token exchange performs policy selection, replay recording, audit logging, and token minting; server routing exposes it as an authentication operation while redaction and MCP exclusions keep identity material away from logs/tools:
security/authn/oidc/tokenExchange.ts:58-227,server/serverHelpers/serverHandlers.js:40-47,server/serverHelpers/serverUtilities.ts:85-117,server/serverHelpers/serverUtilities.ts:268-288,server/serverHelpers/serverUtilities.ts:606-619,components/mcp/tools/operations.ts:148-164. - Token scope is carried through minting, refresh, validation, impersonation, and SQL dispatch before centralized operation authorization:
security/operationScope.ts:18-33,security/tokenAuthentication.ts:162-175,security/tokenAuthentication.ts:244-304,security/tokenAuthentication.ts:323-347,security/impersonation.ts:39-45,sqlTranslator/index.ts:76-87,utility/operation_authorization.ts:468-511. - The CLI requests GitHub’s ambient workload identity and exchanges it only after explicit credentials and stored tokens are unavailable:
bin/workloadIdentity.ts:31-119,bin/cliOperations.ts:766-831. - Design notes and focused suites cover the subsystem, scope propagation, CLI, MCP, and server plumbing:
DESIGN.md:193-214,unitTests/security/authn/oidc/tokenExchange.test.js,unitTests/security/authn/oidc/trustPolicyOperations.test.js,unitTests/security/authn/oidc/verifyIdentityToken.test.js,unitTests/security/tokenOperationScope.test.js,unitTests/security/tokenOperationScopeMinting.test.js,unitTests/security/tokenScopeRefresh.test.js,unitTests/bin/workloadIdentity.test.js,unitTests/components/mcp/tools/operations.test.js,unitTests/server/serverHelpers/serverUtilities.test.js.
Verification
git diff --check origin/main...HEAD passed. Targeted tests could not run because this checkout has no local Mocha installation and restricted network access prevented npx from fetching it (EAI_AGAIN).
Review coverage
| lens | outcome |
|---|---|
| gemini | pruned — pruned (policy minimal) |
| cursor-grok | pruned — pruned (policy minimal) |
| cursor-composer | pruned — pruned (policy minimal) |
| codex | ok — graded leg — produced review.md + comments.json |
| domain | pruned — pruned (policy minimal) |
Pre-push review of feat/oidc-trusted-publishing (2b27671) vs origin/main by codex.
Review emphasis: Dispatch-configured.
— codex review, submitted by the dispatch review gate
…eration Closes the two remaining Barber AI findings. The replay table is now a properly bootstrapped system table: a systemSchema.json stub, a SYSTEM_TABLE_NAMES entry, and a 5.3.0 directive branch, matching the three touchpoints DESIGN.md requires. The previous comment cited hdb_certificate_cache as precedent for going lazy-only, which was wrong — that table is systemSchema-declared AND lazily extended, and the lazy half exists only because an expiresAt TTL is not expressible through CreateTableObject (confirmed: no systemSchema entry declares one and CreateTableObject has no support for it). This matters beyond tidiness: a table auto-provisioned by replication is created without the `audit` flag its schema declares, and auditing IS the replication feed, so a node that first learned of this table from a peer could end up with a non-replicating copy — losing exactly the cross-node replay protection the table exists to provide. That also forced the `??` short-circuit out of getTokenUseTable. With a bootstrap stub always present, short-circuiting on existence would have meant the TTL was never applied on any node — records accumulating in a system table forever. table() now runs once per process regardless, layering the TTL on top, as hdb_certificate_cache does. The exchange tests move their seam to the table factory accordingly, since seeding databases.system no longer intercepts it. Separately, an export job re-entered the SQL permission check with its own `operation: 'sql'`, because the checked parse is stashed on the top-level request while export dispatches the nested search_operation. A token scoped to `export_local` was therefore denied by its own job — fail-closed, so a broken feature rather than a hole, but the natural scope for an export-only CI identity did not work. serverUtilities now stamps the real operation onto the nested request and checkASTPermissions prefers it. This is also the only authorization that runs in the job worker, which never invokes the outer gate. Also guards the expanded-scope memo with an instanceof Set check: it rides on hdb_user, a job persists that user into hdb_job.request, and msgpackr returns a Set as a plain Array — .has() would then throw out of the auth gate as a 500 rather than a clean denial. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tching `add_oidc_trust` with `"enabled": "false"` stored an ENABLED policy. Joi's boolean converts by default, but validateBySchema keeps only `result.error` and discards the converted value, so the string survived to `req.enabled !== false` — true for `"false"` — and `readPolicies` filters on the same comparison, so nothing downstream caught it either. An operator disabling a policy this way got no error and a policy that kept minting tokens. `Joi.boolean().strict()` now rejects it outright: a revocation control has to fail closed. Claim matching was already exact, but nothing pinned it — replacing `accepted.includes(actual)` with a `startsWith` left every OIDC test green, which makes it the one escalation-critical invariant a future refactor could relax silently. Prefix matching on `repository`/`sub` is the classic trusted- publishing escalation, since `HarperFast/my-app-evil` is a name anyone can register. Added negative cases in both argument orders, and confirmed they fail against exactly that mutation before keeping them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ding processAST computed a permission denial and then threw it away. The guard read `permissionsCheck && permissionsCheck.length > 0`, but checkASTPermissions returns null or a PermissionResponseObject, which has no `length` — so the test evaluated `undefined > 0` and was always false, and the statement executed anyway. This only bites where processAST is the FIRST checker rather than the second. A direct SQL call arrives with permissions_checked already true, set by chooseOperation, whose own guard is a correct bare truthiness test. An export job is the case that does not: it re-parses from its nested search_operation, and in the job worker no outer gate runs at all — so the denial dropped here was the only one standing. Now a bare truthiness test, matching serverUtilities. The existing scope tests all asserted that a denial is COMPUTED; none asserted anyone acts on it, which is why a dead consumer went unnoticed. Added a case that drives processAST itself, confirmed to fail against the old guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A reviewer pointed out the dead `permissionsCheck.length > 0` guard is pre-existing and affects all SQL authorization, not just this feature, and asked for it as its own change with its own coverage rather than bolted onto an auth PR. Agreed — it is now #2202, with tests that drive processAST directly and cover the allowed and already-checked paths too, so it cannot start denying statements that were always permitted. This PR does not depend on it. The outer gate in serverUtilities refuses an out-of-scope job operation and sqlWriteScopeDenial refuses write SQL, both through correct truthiness tests, so export_local + DELETE is already refused at the front door. Left a note at the call site pointing at #2202 so the next reader does not re-derive it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…body
`checkASTPermissions` resolved the token-scope operation as
`jsonMessage.api_operation ?? jsonMessage.operation`. On the direct-SQL path
`jsonMessage` IS the client's request body, and that check is the ONLY gate
there — the `sql` branch of chooseOperation is mutually exclusive with its
verifyPerms call. So a caller could send
`{operation: 'sql', sql: '...', api_operation: '<whatever their scope allows>'}`
and run arbitrary SQL under it. Reproduced against this branch; the regression
test was confirmed to fail before the fix.
I introduced this in 11f2280, carrying a job's real operation to the nested
check on a request property. That is reverted. The operation now comes from an
explicit argument or the dispatched `json.operation`, never from a field on the
message — chooseOperation passes the operation it already resolved.
Stripping `api_operation` at the ingress points was the first fix I tried, and
it is the wrong shape: it leaves the check trusting a body property and makes
safety depend on every current and future entry point remembering to strip. The
property is gone instead.
The trade is that a job's SQL is checked as `sql` rather than as `export_local`.
That changes no outcome today, because the branch in processAST that would act
on the denial is dead — PermissionResponseObject has no `length`, so its guard
never fires (#2202). When #2202 makes that branch live it needs a carrier for
the job's operation that a client cannot forge; a request property is not one,
however carefully it is stripped. Recorded at both sites so the next reader does
not re-derive it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…abled strictness Two review follow-ups. hdb_oidc_token_use is created through the same CreateTableObject + bridge.createTable path as hdb_oidc_trust, hdb_deployment, and hdb_secret, but skipped the is_hash_attribute __dbis__ patch all three of those apply. If the reason they need it holds — harperdb@4.x derives the LMDB DBI open flags from that field, and its absence opens the DBI with DUPSORT and throws MDB_INCOMPATIBLE — then a 5.3.0 install that later downgrades hits it here too. The helper is now parameterized by table name and applied on both branches for both tables, so the asymmetry is gone rather than undocumented. The `.strict()` fix on `enabled` had no test: dropping it back to a plain Joi.boolean() left the whole OIDC suite green, which is a poor state for a revocation control whose failure direction is "stops revoking". Added cases for the coercible values and for a genuinely disabled policy, and confirmed they fail against the un-strict schema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…table The expiresAt TTL is installed by the table() call on the exchange path, so a node that never performs an exchange has the table from this directive but never registers the TTL locally — replicated replay rows land there and are never evicted. Documented rather than fixed: hdb_certificate_cache has the identical shape, so the real fix is installing the TTL at system-table setup for every lazily-extended system table, not special-casing this one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reverting the processAST guard to #2202 removed the one test that asserted this invariant is ENFORCED rather than merely computed, and the safety argument now rests entirely on chooseOperation's front-door gate — which had no enforcement test of its own. The rest of the scope suite only checks that verifyPermsAST returns a denial object, which is exactly how a dead consumer goes unnoticed. Three cases on the real dispatch path: an export job carrying nested write SQL outside the scope throws 403, an export whose own operation is outside the scope throws 403, and an in-scope export still runs — the last so this cannot pass by refusing everything. Confirmed they fail when the front-door gate is given the same dead-guard shape (`astPermCheck && astPermCheck.length > 0`) that made the inner branch a no-op. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g in a comment The interaction between this PR and #2202 was documented only in prose, and the two can merge in either order. Removing the forgeable operation carrier leaves checkASTPermissions falling back to jsonMessage.operation, which at the processAST call site is the nested search_operation's own `sql` — so once #2202 makes that branch live, an export_local-scoped token 403s on its own export job. Added a tripwire that drives evaluateSQL with the exact shape export.ts:363 dispatches and asserts an in-scope export is not refused by the permission gate. It passes today and was confirmed to fail with #2202's one-line change applied on top, so whichever PR lands second turns CI red rather than shipping a silently broken feature. The comment on it says what to do when it fires — supply the job's real operation through a carrier a client cannot set, rather than relaxing the scope check. Preferred this over making apiOperation a required parameter: that turns the missing carrier into a compile error the next author satisfies by passing jsonMessage.operation, which is the wrong value and compiles clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tripwire compared against 403, which is UNAUTHORIZED_RESPONSE in the very file it exists to watch — so changing that constant would leave it green while the refusal it guards against still happened. A tripwire must not depend on a constant its own target owns. Now asserted by shape: the permission path is the only one that calls back with a bare numeric status, while every other failure forwards an Error. evaluateSQL drops the second callback argument on error, so the denial object never reaches the test and the number is the whole signal — which also rules out asserting on the PermissionResponseObject shape directly. Verified across four states: passes today, fails with #2202's guard applied, still fails with #2202 applied AND the status changed to 401 (the case the old assertion missed), and passes again restored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ken keep its token
Two findings from kriszyp's review.
The exchange-time recheck stopped at audience/claim specificity, so a row that
reached the table another way — replication from an older node, a restored
backup, a direct system-table write — could still fail OPEN in two shapes:
operations: 'deploy_component' a scalar, not an array. hasOperationScope
tests Array.isArray, so the scope was silently dropped and the token minted
UNSCOPED, carrying the policy user's entire role. A malformed narrowing must
never widen.
enabled: 'false' a string. `row.enabled !== false` is true for
it, so a policy an operator disabled kept minting tokens.
Both are now refused rather than normalized, by running the SAME validators the
add path uses (validateOperations, validateClaimConstraintShape) against the raw
row before toRecord touches it — normalizing first is exactly what hid them. Two
implementations of "is this row valid" is how a write path and a read path drift
apart, so they share one. Regression cases write each shape straight to the
store, with a control proving a well-formed direct write still authenticates.
Separately, `token` is stripped from every CLI request body as transport-only,
on the stated grounds that no operation takes a top-level `token`. This feature
broke that premise: exchange_oidc_token's identity token IS its request, so the
generic CLI path sent it without the field it requires and the issuer-agnostic
operation was unusable there even though direct HTTP worked. The strip is now
keyed on the operation rather than dropped — the mistyped-`setup` case it guards
is real — with tests for both directions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g registry support Once a cached key set passed its TTL, a failed refresh returned a stale key but advanced no clock — `fetchedAt` is only set on success — so every subsequent request wave started discovery again and rode the same timeout before falling back to the same stale key. The exchange is unauthenticated and picks its issuer from an unverified JWT, and key ids are public, so an anonymous caller could keep that cycle running for the length of an issuer outage: exactly when the stale-key grace is meant to absorb load rather than generate it. A failed refresh is now recorded on its own clock, and while a usable stale key is on hand the fetch is skipped entirely for the backoff interval — skipping the fetch is the point, since that is the expensive half. Recorded even when no stale key rescues the request, so the backoff also covers an issuer whose keys we have never held, and cleared on success. Also corrected the dynamic-operation test's claim. It said component-registered operations are supported; the registry is process-local, add_oidc_trust runs on main, and server.registerOperation runs in a worker whose announcement carries only name→thread routing. The test asserts the delegation to validateOperations, not the topology, and now says so — the production behavior is that such a policy is rejected, which fails closed and is shared with add_role/alter_role. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The skip-fetch gate required a stale key, so it never fired for an issuer whose keys had never been cached — every request still rode the full discovery and fetch timeout, and `failedRefreshAt` was written but never read on that path. The comment above it claimed the opposite. That is the worse half of the case: with no cached key there is nothing to fall back to, and the exchange is unauthenticated with the issuer chosen from an unverified JWT. The backoff now applies regardless: a stale key is served when there is one, and otherwise the request is refused for the interval instead of re-driving the fetch. Fails closed. The cost is that a legitimate first exchange waits out the interval after a blip, which is bounded and the right side to err on for an unauthenticated endpoint. This half is testable without a time seam, unlike the expired-cache half — so there are now two cases: repeated failures for a never-cached issuer stop producing fetches, and a recovered issuer is picked up again once cleared. The first was confirmed to fail against the stale-key-gated version. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tests missed
Two review follow-ups on the stored-policy validation.
A row the exchange refuses still listed as healthy. `storedPolicyProblem` ran
only on the exchange path, and `toRecord` normalized exactly the shapes it
exists to refuse — `enabled: 'false'` rendered as `enabled: true`. So a row
arriving by the routes this validation defends against would fail every exchange
with the deliberately opaque 401 while `list_oidc_trust`, the one command an
operator runs to check, confirmed the trust was fine. Validation now runs on
both paths; the exchange refuses, and a listing reports `invalid_reason` and the
stored `enabled` as-is rather than coerced. A listing's job is to describe what
is stored.
The two tests for the fail-open shapes did not actually pin their guards, which
a mutation check demonstrated:
operations — the case seeded a STRING scalar, so validateOperations iterated it
character by character and refused the row by reporting 'd' as an unknown
operation: right outcome, wrong check, guard deletable with tests green. Now
seeded with a number, where `for (const op of 42)` throws TypeError and turns
one malformed row into a 500 for every exchange against that issuer.
claims — the case used a wholly-bad shape that matchTrustPolicyClaims refuses
downstream anyway. Now a constraint list that MATCHES on its string entry and
carries a non-string alongside it, which only the shape validator refuses.
Both confirmed to fail with their guard deleted, and the string-scalar case is
kept as well since it is the shape most likely to arrive.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dc_trust `invalid_reason` covered row shape only, but the exchange also refuses a well-formed row whose user has since been deleted or deactivated — with the same opaque 401. That is the same availability trap the previous commit closed, reached by its most mundane cause: someone removes the CI user, every deploy starts failing, and the one command an operator would run to check reports the trust as enabled and healthy. Annotated in listOidcTrust rather than in readPolicies, deliberately. It is one users-cache read for the whole listing on an SU-only path; doing it per row in readPolicies would put a user lookup on the unauthenticated exchange path, which already resolves the user itself at the point it matters. A shape problem still wins, being the more fundamental complaint. Both cases confirmed to fail with the annotation removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…es on The rule that a shape problem outranks a missing user was stated in the comment and the commit message but pinned by nothing: every shape case named a valid user and both user cases were well-formed, so no test had a row with both. The `continue` implementing it could be mutated to a no-op with all tests green. Added a row that is both malformed and names a deleted user, asserting the shape problem is what surfaces — it is the more fundamental complaint, since the row stays refused even if the user is restored. Confirmed it kills exactly the mutation that survived before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5af0975 to
d5a1821
Compare
STALE_KEY_GRACE_MS was the one JWKS guard nothing pinned: replacing it with an unbounded stale fallback left the whole suite green. That bound is the security half of the blip-tolerance tradeoff — `fetchedAt` advances only on a SUCCESSFUL fetch, so without the ceiling a key the issuer has pulled stays honored for the entire length of an outage instead of 24 hours. getSigningKey now takes an optional `now`, mirroring the clockTimestamp seam verifyIdentityToken already exposes rather than inventing a second convention — production callers pass nothing. Reaching this branch otherwise needs a cache aged past an hour, and this repo bars new fake timers, which is why the gap was previously documented rather than closed. Both sides asserted: a cached key is still served at grace−1 (a blip must not break deploys) and refused at grace+1. Confirmed the reviewer's exact mutation now dies, and that it reached dist/ before running — a .ts-only edit would have been a silent no-op since .mocharc sets no --conditions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kriszyp
left a comment
There was a problem hiding this comment.
Great PR!
🤖 Reviewed with Codex
| } catch (error) { | ||
| // Recorded whether or not a stale key rescues this request, so the backoff also covers the | ||
| // issuer whose keys we have never held. | ||
| failedRefreshAt.set(normalizedIssuer, Date.now()); |
There was a problem hiding this comment.
clearJwksCache() clears failedRefreshAt at security/authn/oidc/jwks.ts:72, but a refresh already in flight can fail afterward and execute this set, recreating the backoff. The next call then exits at security/authn/oidc/jwks.ts:233-235 without fetching, so an operator’s forced reread remains blocked for 30 seconds during an outage/recovery race. The existing cacheGeneration guard already prevents orphaned successful loads from repopulating the key cache; please apply the same generation or ownership rule to failure state and add a delayed-failure → clear → recovered-issuer regression test.
— KrAIs (Codex)
`add_oidc_trust` is the third main-thread caller of `validateOperations`, and until this change its own source carried a `Known limitation` note saying a component-registered operation "is NOT recognized here and a policy naming one is rejected", pointing at this bridge as where the fix belonged. That note is now false, so remove it rather than leave a comment describing behaviour the code no longer has. The operation was absent from the checkout when this branch started, which is why the earlier rounds could only cover add_role/alter_role and impersonation. Add the two cases that were always wanted: a trust policy naming a component-registered operation is accepted, and one naming an unregistered operation is still rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ations allowlist (#2260) * Propagate grantable component operations to the main thread `server.registerOperation({ requiresSuperUser })` marks an operation grantable in a role's `operations` allowlist, but that mark landed only in the worker that registered it (components load per-worker). Meanwhile `validateOperations` is consulted on the main thread — by add_role and alter_role, by impersonation payload validation, and by OIDC trust policies — so naming a component-registered operation in any of those was rejected as "not a valid operation name or group" even though the operation existed and was designed to be grantable. The OPERATION_REGISTERED announcement already crosses that boundary for execution routing, so carry grantability on it too and mirror the mark on main. This only widens what an allowlist may name; enforcement is unchanged, still running on the worker's own `chooseOperation`. Also arm the thread-exit cleanup when the registry gains its first entry rather than on the first forwarded call, so a worker that registers and exits without ever being called no longer leaks its entries, and revoke the mirrored mark when the last registering worker is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Address cross-model review: scope the grantable mark's ownership Claim a mirrored name for thread-exit cleanup only when the mirror is what made it admissible. As written, the ownership set was unconditional, so a name that main had already registered itself — or an enum/group name — was revoked when the last worker offering the same name exited, which is the opposite of what the set exists to prevent and of what its comment claimed. Flagged independently by both review lenses. Route both prune paths through one `dropRegistration`: the failed-send path in `executeRemoteOperation` dropped the routing entry without revoking the mark, so routing and grantability could disagree about whether an op was still offered. Also cut the comments the review flagged as narration or as duplicating the note now carried in server/DESIGN.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Keep worker-mirrored grantability in its own registry The ownership probe added in the previous commit only protected marks that predated a worker's announcement, which leaves a reachable hole: on a hot deploy `restartWorkers` awaits `loadRootComponents()` before it begins draining the old workers (`server/threads/manageThreads.js`), so a `startOnMainThread` component can register an operation the retiring worker also offered. The worker's exit then revoked the main thread's own mark and role validation started rejecting an operation that was registered and executable. Track mirrored names in a separate set that `validateOperations` unions instead of sharing one. The two threads can now register the same name independently and neither can revoke the other, which removes the ownership question rather than narrowing it — the probe and its bookkeeping set are gone. Found by the round-2 cross-model review, which also supplied this approach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Track mirrored grantability per declaring worker The planning review returned better-alternative-exists on the previous approach, and it was right: a name-level mirror set cannot express which worker declared the operation grantable, so it did not enforce the invariant the design claimed ("admissible iff a live worker declares it grantable"). Concretely, a rolling deploy whose new generation keeps an operation but drops `requiresSuperUser` left the name admissible with no live declarer: the routing set stayed non-empty via the new workers, so the mark was never revoked, and `add_role` accepted a grant whose execution then failed closed with operation-not-found. Track claims as name -> Set<declaring threadId> and re-derive the mirrored mark from live claims, so grantability is retracted when a thread withdraws it or exits even while other workers keep routing the name. Also ignore an announcement from a thread already reported dead: exit notification is deduplicated for the process lifetime, so such an entry could never be cleaned up afterwards. Extract the thread-exit cleanup and export a test seam for it, which is what finally lets the revocation paths be tested at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Close the missed-exit window and retract on failed sends Two real gaps found by the gemini and cursor-composer legs, corroborating each other on the second one. Arm the main-thread listeners at module load instead of on first use. `attachMainListeners` ran lazily from the registration handler, but thread-exit notification fires once per thread and is dropped outright when no listener is attached yet — so a worker that died before its first announcement was processed left a registration nothing could ever clean up, and the exited-thread guard never learned about it. serverUtilities imports this module during its own load, before any worker exists, so arming at load is well ordered. Retract grantability when a failed send prunes a dead originator. `executeRemoteOperation` dropped the routing entry but left the claim, so a dead worker could keep a name admissible while a surviving worker that never declared a permission kept routing it — the same false-admissible case this change exists to close. Also guard the ITC payload destructure. A malformed OPERATION_REGISTERED with no `message` would have thrown on the main thread; the envelope is trusted and in-process, but three review rounds have now flagged it and the guard is one expression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Harden the lifecycle tests and trim narration Synthetic thread ids in the new tests were small positive integers inserted permanently into the module-global tombstone set, which the `after` hook cannot clear — a later suite starting a real worker in the same process could have been assigned one of those ids and had its legitimate announcement ignored. Use ids the runtime will never assign. Cover the failed-send retraction through its production trigger rather than only the exit seam: a forward whose `sendToThread` reports a dead port must retract the claim, not just the route. Also drop comments the review flagged as narrating the line beneath them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Skip cleanup for operations the exiting thread never registered Both suggestions from the gemini review, and both behaviour-preserving: a grantability claim implies a registration, since claims are only recorded alongside one, so an operation the dead thread was not registered for can have no claim to retract either. `handleThreadExit` now continues when the id was not in the routing set, and `setWorkerGrantable` only re-derives the mirrored mark when a claim was actually removed, instead of unregistering a name it never held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Cover the OIDC trust-policy consumer now that #2173 has landed `add_oidc_trust` is the third main-thread caller of `validateOperations`, and until this change its own source carried a `Known limitation` note saying a component-registered operation "is NOT recognized here and a policy naming one is rejected", pointing at this bridge as where the fix belonged. That note is now false, so remove it rather than leave a comment describing behaviour the code no longer has. The operation was absent from the checkout when this branch started, which is why the earlier rounds could only cover add_role/alter_role and impersonation. Add the two cases that were always wanted: a trust policy naming a component-registered operation is accepted, and one naming an unregistered operation is still rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Correct the OIDC test comment that still described the old gap The comment on "accepts an operation registered in this process" said a component's operation "is NOT recognized here in production" and named this bridge as where the fix belonged. The test itself is unchanged and still correct — it asserts the delegation to validateOperations — but its explanation described behaviour this branch removes. Surfaced by the review leg grepping for leftovers after the source comment came out, which is the half-true remnant that sweep was looking for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Retract the permission entry when a re-registration drops the flag Cross-model review found a real authorization divergence this branch introduced. Registering an operation with `requiresSuperUser` installs a `requiredPermissions` entry keyed by the operation name; re-registering the same name without the flag left that entry in place. Before this branch that was inert, because the operation could never have been granted in the first place. Now main retracts the mirrored grantable mark on the re-announcement while the worker keeps honouring a role grant persisted earlier, so the declaration and the enforcement disagree — reachable whenever the handler's own `.name` matches the operation name, which is a natural way to write one. Retract the entry alongside the mark, tracking the names this API installed so a component re-declaring a built-in's name cannot strip the built-in's permission. Tests cover both directions: a persisted grant stops being honoured once the declaration is dropped, and an entry registered by anyone else survives. Also finishes the limitation cleanup the review caught mid-flight and trims the comments it flagged as narration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Narrow the ownership claim in the re-registration comment The comment overstated the guard: a bare flagless registration cannot clear a built-in's entry, but declaring that name first puts it in declaredPermissionNames, so a later flagless registration can. The declaring call already overwrote the built-in entry at that point, so this only follows it — but the comment claimed a guarantee the code does not make. Also drops the two comments the review flagged as restating the line beneath. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Reuse the shared dead-thread registry instead of a second one Review pointed out that jobs launch a fresh `autoRestart: false` worker per job (server/jobs/jobRunner.ts), so a per-thread tombstone here grows with every completed job on a long-lived node — not once per worker restart, which is what the comment claimed. `manageThreads` already records exactly this in `notifiedDeadThreadIds`, and records it before firing exit listeners, so the local set was duplicating state that was already there and already correct. Expose a sync `hasThreadExited` from `manageThreads` and read that instead. `isThreadRunning` cannot serve: it is async because it awaits process-group confirmation, and this runs on a synchronous announcement path. Export `notifyThreadExit` too, which lets the lifecycle tests drive the real exit path and removes `notifyThreadExitedForTest` from production entirely. The tests now exercise the `onThreadExit` wiring they previously bypassed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Drop the inaccurate export-list comment It placed the dedupe "above" the export when notifyThreadExit is defined far below it, and an export list is not where that rationale belongs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Describe the tombstone without naming a private set The test comment still said exitedThreadIds, which no longer exists. Phrased without naming the holding set so it stays true regardless of which module owns it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Keep the dead-thread tombstone local to this module Reverts the shared-registry reuse from d97a5cc. CI bisects a Windows regression to that commit: `Integration Tests 2/6 (Windows)` fails with it on two independent builds (375s, 389s, then 465s on a fresh build) and passes without it (259s), matching main at 262-280s. `manageThreads.js` is untouched again as a result. I do not have a root cause. Reading `notifiedDeadThreadIds` instead of an equivalent local Set should not cost minutes of HTTP-worker readiness, and it does not reproduce on macOS, so the revert is on evidence rather than understanding. Kris's underlying point stands and is answered in the comment instead: the set holds one integer per dead thread, which is the same growth profile manageThreads already accepts for notifiedDeadThreadIds a few lines from where it records them. Narrowing it to threads that already hold a registration was tried and rejected — it defeats the guard's purpose, because the case it exists for is a thread whose FIRST announcement is in flight when it dies, and such a thread holds no registration at exit time. A unit test covers that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Reinstate the shared dead-thread registry Reverts 9d66638, which was made on a conclusion I have since retracted. I had bisected a Windows failure to the shared-registry change and reverted it on that basis. The bisect was noise: the same test fails at 350s with the change reverted, and ranges 259-465s across builds with identical code, so ~200s of variance was being read as a 110s signal. Windows is red here for #2273 (risk-query and describe_all: npm work, then restart_service, then route readiness — open and reproducing on main) and #2313 (set_configuration), neither reachable from this diff. So this restores the better shape, which is also what review asked for: no second dead-thread registry beside the one manageThreads already maintains, and notifyThreadExitedForTest is out of production surface again, with the lifecycle tests driving the real onThreadExit event instead of a module-local stand-in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #2171. Now includes the token operation-scoping work formerly in #2174 (merged into this branch 2026-08-17, so this reviews as one whole rather than a stack).
Folded in from #2174 — token operation scoping (has an open design question)
An OIDC trust policy may carry an optional
operationslist; the exchanged token carries it as a claim, andverifyPerms/verifyPermsASTintersect it with the user's role, so one Harper user can back several workflows each holding a credential narrower than the user itself. Absent claim = today's behavior exactly.Open design question, unresolved — do not merge to
mainbefore it is settled: @kriszyp asked for per-policy operation trust; @heskew reviewed and preferred it dropped ("one authorization system; least privilege lives in the role"). Folding #2174 in here does not decide that — it only stops juggling a stack. If the answer is "drop it," the four token-scoping commits revert cleanly off the top.The invariant is "can only ever subtract," and getting there took finding six bypasses (across three review rounds plus a local cross-model pass), which is the strongest argument for reviewing this part closely — or for dropping the feature per @heskew, since the attack surface is this wide:
verifyPermsandverifyPermsAST, ahead of the super_user bypass and theoperationsgate-2 grant, which bothreturn nullearly.role.permission.operations— that field is not purely narrowing (gate 2 treats an explicit SU-only listing as a grant), so merging could widen. It travels separately astokenOperations.verifyPermsAST), mutually exclusive withverifyPerms— a gate in only one let aget_status-scoped token run arbitrary SQL until fixed. It also gates on the API operation the caller sent (requestJson.operation), not the handler name — the handler-name form both denied the feature's owndeploy_componentand let a read-scoped token ride anexport_local/export_to_s3job (SQL and NoSQL variants) to exfiltrate data.create_authentication_tokens(NO_AUTH, so it skipsverifyPermsentirely),refresh_operation_token, and impersonation each produce a new credential/principal and would otherwise drop the scope, escalating a scoped token to unscoped. Fixed in6038dadcb; found by the cross-model review.create_authentication_tokenswithpurpose: 'login'(also NO_AUTH) trades for a cookie session, and a session is username-only by construction (session-restore reloads the full user), so the scope cannot survive it. Denied rather than carried. Fixed in31da888c9.A cross-model review of the combined branch is running; findings and adjudication will be added below when it completes.
What this does
Lets a CI runner authenticate to Harper with no stored credential. It presents an OIDC identity token minted by its provider; if that token verifies against a stored trust policy, Harper returns a one-hour operation token for the user the policy names.
Today, after #1876, a deploy workflow carries a 30-day refresh token — full user authentication, one per user (#2018), expiring on a schedule nobody tracks. After this:
Configured once against the instance, and revoked with
drop_oidc_trust:harper add_oidc_trust id=my-app-prod issuer=https://token.actions.githubusercontent.com \ audience=https://my-instance.harperdb.io:9925/ user=ci-deploy \ claims='{"repository_id":"67890","workflow_ref":"HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main","environment":"production"}'Same exchange npm, PyPI, and AWS STS
AssumeRoleWithWebIdentityuse.Where to put attention
The ref-gate requirement is stricter than npm's model, and it is the one thing most likely to be wrong.
validateTrustPolicyClaimsrejects a policy that pins repository + workflow without also pinningworkflow_ref,ref, orenvironment— because otherwise anyone who can push a branch can add the trusted workflow to it and mint a token. npm accepts that shape and mitigates with environment protection instead. The practical cost: a tag-triggered release cannot pin an unknowable tag, so it must name an environment.ref_type: tagis deliberately not accepted as a gate, since anyone with push access can create a tag. If that blocks a real workflow shape, it is a one-line change insecurity/oidcTrust/claims.ts.I dropped
operationsfrom the policy shape that #2171 described. An operation allowlist on the policy would be a second authorization system running beside roles — two places to disagree about what a CI identity may do. Least privilege is now the role of the user the policy names. Workflow config is unchanged.Replay protection is honest about its race.
hdb_oidc_token_userecordsissuer|jtiwithexpiresAtpast the token's own expiry. The get-then-put is not atomic; Harper's OCC may serialize it, but the code does not depend on that. The reasoning (intokenExchange.ts) is that a concurrent replay is not a privilege escalation — whoever holds the token could obtain one operation token anyway — and what this does stop is the realistic leak-then-reuse case. Worth a second opinion on whether that trade is acceptable, since it is the one place I chose not to reach for a stronger primitive.Cross-node replay depends on replication timing. The table replicates like other system tables, but replication is asynchronous, so two simultaneous replays against different nodes can both land.
createOperationTokenis new intokenAuthentication.ts.createTokenscould not be reused: it overwriteshdb_user.refresh_tokenas a side effect, so minting for CI would silently revoke whatever credential that user already held (#2018) — the exact problem this removes. The new function does no user lookup and no write; callers establish entitlement themselves.CLI precedence. The exchange ranks below every configured credential, not above. Adding
id-token: writeto a workflow that still setsHARPER_CLI_REFRESH_TOKENmust not silently change which identity deploys. Tested both directions inunitTests/bin/cliOperations.test.js.This PR now also changes how every operation body is logged. Review flagged that
tokenwas missing from the operations-log redaction list, soexchange_oidc_tokenwrote the raw CI identity JWT at INFO on every call — before the handler runs, so rejected attempts logged an unspent credential. Fixing it meant touchingprocessLocalTransaction, which is shared by every operation:tokenandrefresh_tokenadded to the list. Both also cover pre-existing exposure —tokenonlogin(feat(cli): token env vars for CI/CD auth #1876),refresh_tokenonrefresh_operation_token(a 30-day credential).redactForOperationLog+ an exportedUNLOGGABLE_OPERATION_FIELDS. Not tidying:operationLogis built frommainLoggerat module load, so the logged body can't be intercepted afterwards, and the existing redaction test's assertions sit behindif (info_log_stub.called)— never true in the unit environment, so it has been passing vacuously. The extraction makes the contract testable without logger plumbing.Behaviorally identical for every other operation, but it is a shared path and deserves a look.
Other decisions worth a look
oidc-trustlogger.add_oidc_trustrefuseshttps://github.com/<owner>— the provider default, shared by every repository under an owner, which would make the audience check meaningless.sub. Since 2026-07-15 GitHub emits immutable subjects (repo:org@12345/repo@67890:...) for new repos, sosubparsing would have to handle two shapes forever.repository_id+repository_owner_idare also immune to org-name recycling.workflow_pathis derived fromworkflow_refby splitting on@refs/, so a tag release can pin the workflow file without knowing the tag.DEFAULT_EXCLUDED.list_oidc_trustmatches thelist_*glob, and the policy set names exactly which repository and workflow are worth compromising.Deep review (Harper-domain pass, 2026-08-18)
A single-pass Harper-domain deep-review (the lens the earlier cross-model run's domain leg failed to execute) found four issues; all fixed in
922d260a6except where noted:Scoped token could mint a long-lived credential.Fix was incomplete — see round 2, finding 1.create_authentication_tokensis NO_AUTH, so the scope gate never runs. The922d260a6fix denied a scoped caller, which missed the common case: an unscoped policy. Superseded by the provenance guard in2a5a509ba.verifyPerms/verifyPermsASTgate; the application resource path authorizes via table-levelcheckPermissionand does not consult it. So a scoped operation token used as a Bearer against REST/GraphQL keeps the role's full CRUD. I narrowed thetypes.tsguarantee to say so; full resource-path enforcement is a deliberate follow-up on the same surface as CORE-3061 (GraphQL bypasses ops allowlist), not done here. Until then, point a policy'suserat a role that is itself least-privilege.audit: true.hdb_oidc_token_usewas created without an explicit audit flag, so withlogging.auditLog:falseits rows never replicate — silently dropping cross-node replay protection. Fixed to match its siblinghdb_oidc_trust.add_oidc_trustenforces the repo+workflow+ref pins, but the exchange only backstopped empty-claims /pull_request_target. A row arriving via replication from an older node or a restored backup could be under-specified;findMatchingPolicynow re-runs the specificity checks and skips (logs) any row that fails.The pass also traced and cleared: token verification (alg/kid/aud/iss/exp confusion), JWKS SSRF, the replay fingerprint, the credential-minting carry-forwards, stale-scope cache leaks, body-controlled auth, trust-policy CRUD authz, MCP exclusion, the upgrade-directive version tag, and the
expiresAtTTL.Review coverage
KrAIs)2b27671584adclaude[bot])922d260a6; finding 1's fix was incomplete and is superseded belowcb1kenobi)2a5a509baclaude[bot])2a5a509baThe two cross-model reviews were run independently and converged on four findings (1, 3, 4, 6 below), which is the main reason I treated those as real rather than speculative. Every claim was verified against the code before acting — one was verified and then declined, see 7.
Cross-model review round 2 — fixes in
2a5a509bahasOperationScope(...), but a trust policy carriesoperationsonly when the operator opts in, so the ordinary exchanged token is unscoped and the guard was false for exactly the headline example. It reached the standing path with a caller-controlledexpires_inand a 30-day refresh token. Mint provenance is now a signed claim on every tokencreateOperationTokenproduces, lifted onto the principal invalidateToken, and refused ahead of the user lookup — so a refused request costs no read and writes nothing.applyImpersonationcarries it forward, since it returns a new principal and would otherwise launder the marker.read_onlyscope could run write SQL. (Codex.) Confirmed: the group expands to includesql, andverifyPermsASTreturnsnullfor a super_user before any table check — soDELETEpassed. A write statement now additionally requires its matching data operation in scope. I took this over tracking which group admittedsql: it needs no provenance through expansion, and it is what already separatesread_onlyfromstandard_user. A bare['sql']therefore admits SELECT only.job_workflow_refaccepted as a caller-ref gate. (both models.) Confirmed. It names the reusable workflow that ran, not the caller — its@refis constant however it is invoked, so it admitted any branch of any caller repo referencing that workflow. Removed from the ref row; still valid as a workflow pin.iatoptional,expunbounded against the clock. (both models.) Confirmed both halves: the ceiling was skipped entirely wheniatwas absent, andjsonwebtokennever rejects a futureiat, so a pair shifted equally far forward kept a small delta.iatis now required, a futureiatis rejected outside tolerance, andexpis bounded against the verification clock independently.rewiremutations in tests. (Codex.) Correct — AGENTS.md prohibits newsinon/rewire. Removed all six; the denial cases no longer need stubbing now that the guard runs before any I/O, and the two positive-path cases were already covered by pre-existing tests (lines 428 and 486 onmain).types.tsfield.validateOperationsis consulted on main byadd_role,alter_role, and impersonation validation identically, and theOPERATION_REGISTEREDbridge carries only name→thread routing. It fails closed — a rejected policy, never a widened one — so this is a DX bug, not a bypass. I corrected my overclaiming comment and filed the bridge fix separately rather than riding a shared-subsystem ITC change on an auth PR.types.ts) instead of quietly changing the model.Cross-model review round 3 — fixes in
8799d450b,11f2280c6,76718e6b3,5178cc799A third model (Barber AI) plus a follow-up Claude pass. Every finding was verified against the code before acting, and two of them corrected earlier conclusions of mine. All review threads are resolved.
The two that were genuinely dangerous:
Replay protection was bypassable (
8799d450b). The fingerprint hashed the whole token, justified in my own comment by "a replayed token is byte-identical by definition". That premise is false: the signature segment is covered by nothing, and base64url decoding ignores the surplus low bits of its final character. I reproduced it — 15 alternate spellings of an RS256 signature all verify, each hashing differently, so one leaked identity token bought 16 operation tokens. ES* malleability (s → n−s) is a second vector. Now keyed on the signed input (header.payload). The regression test was confirmed to fail against the old fingerprint before being kept.A computed SQL permission denial was being discarded (
5178cc799).processAST's guard waspermissionsCheck && permissionsCheck.length > 0, butPermissionResponseObjecthas nolength— soundefined > 0was always false and the statement ran anyway. This only bites whereprocessASTis the first checker: an export job, which in the job worker has no outer gate at all. I had earlier concluded this path failed closed; it did not, and the follow-up review was right to push back.Also fixed: an issuer whose
issends in/(Azure AD v1) could never authenticate; a naturalaudiencevalue silently produced a policy that could never match (rejected at write time now, with a test pinning the check to the CLI's realnormalizeTargetoutput); the replay table now has the three standard bootstrap touchpoints, which matters because a table auto-provisioned by replication is created without theauditflag — and auditing is the replication feed, so a peer-provisioned copy would have been silently non-replicating;"enabled": "false"stored an enabled policy (Joi coerces,validateBySchemadiscards the coerced value) — now.strict(); export jobs carry the real top-level operation to the inner scope check; the expanded-scope memo is guarded withinstanceof Set(msgpackr returns aSetas anArrayacross thehdb_jobboundary); plus audit client-IP, non-throwing audit emit, logged JWKS failures, uniform rejection when the trust table is missing, issuer filtering pushed into the scan, the CLI exchange timeout, blank-CI-secret identity switching, the JWKS cache-clear race, and two stale JSDoc symbols.Claim matching was already exact, but nothing pinned it — a
startsWithmutation left all 195 OIDC tests green. Prefix matching onrepository/subis the classic trusted-publishing escalation (HarperFast/my-appadmittingHarperFast/my-app-evil), so there are now negative cases in both argument orders, verified against that exact mutation.Filed rather than fixed: #2201 — token scope is not enforced on the REST/GraphQL resource path. Both remedies change user-visible behavior and it is entangled with the open design question below, so it belongs to a human. The boundary is documented in
types.tsand DESIGN.md meanwhile.Open / not done
pull_request_targetveto and whyref_type: tagis not a ref gate), the generic-issuersubrequirement, and the CLI credential source, plus a5.3release-notes entry. Written against this branch rather than this description, so it already reflects the issuer-agnostic split noted at the top. It is a draft gated on this PR via a companion marker.The exchange does not write toResolved — exchanges now emitAuthAuditLog.AuthAuditLogon success and failure, same stream and switches as Basic/Bearer/mTLS, using thebaseRequestserverHandlersinjects forNO_AUTH_OPERATIONS.unitTests/security/oidcTrust/, 12 inciIdentityToken.test.js, 5 added tocliOperations.test.js), including an end-to-end exchange with a real signed token, a real JWKS fetch, and real token minting — only the network and the two system tables are displaced. The touched suites run 310 green in total.Unrelated pre-existing failure
unitTests/server/serverHelpers/uwsServer.test.js— "rejects a body over maxBodyBytes with 413" fails withEPIPE, consistently across three runs. I stashed this branch and rebuilt to confirm it fails identically on cleanmain. Not from this PR, but nobody appears to be tracking it.🤖 Generated by Claude Code. LLM-authored; deserves a human read — especially the token operation-scoping section (open design question) and the auth hot-path changes in
verifyPerms/verifyPermsAST.