Skip to content

fix(security): honor the SQL permission denial processAST computes - #2202

Merged
kriszyp merged 1 commit into
mainfrom
fix/process-ast-permission-guard
Aug 25, 2026
Merged

fix(security): honor the SQL permission denial processAST computes#2202
kriszyp merged 1 commit into
mainfrom
fix/process-ast-permission-guard

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

processAST computed a SQL permission denial and then discarded it. The guard read
permissionsCheck && permissionsCheck.length > 0, but checkASTPermissions returns null or a
PermissionResponseObject — which has no length, so undefined > 0 was always false. No denial
reaching that branch has ever been honored.

Fixing the guard is one line. The rest of this PR is what the live guard exposes — the work the
ordering note on this PR asked
whoever picked it up to do, plus one thing that note did not anticipate.

  • sqlTranslator/index.ts — bare truthiness, and read the auth async-context state once (behind
    the permissions_checked short-circuit, so the already-checked call pays nothing).
  • server/serverHelpers/operationAuthorizationState.ts — the existing store now carries
    {bypassAuth, apiOperation} rather than a bare boolean, plus runWithDispatchedOperation. Both
    wrappers preserve an existing carrier; two frozen shared objects serve the common no-carrier
    path, so it allocates only when there is something to carry.
  • server/jobs/jobProcess.ts — establishes the carrier in the job worker, and sanitizes the
    persisted row on load (both parsed_sql_object positions). Without the carrier an
    export_local-scoped credential is refused on its own export, since the worker re-parses the
    nested search_operation and the check sees sql.
  • server/serverHelpers/serverUtilities.ts — deletes a body-supplied
    search_operation.parsed_sql_object at dispatch, next to where bypass_auth is already stripped.
    Only the nested one here: the top-level is overwritten by this dispatch's own parse.
  • server/jobs/jobs.tsgetJobsInDateRange runs Harper's own fixed system.hdb_job query
    through evaluateSQL and SqlSearchObject hardcodes operation: 'sql'; that statement is
    bypassed rather than carried (see Add a CODEOWNERS file to set PR reviewers #5 below — they are not interchangeable).

For the human reviewer

  1. A theoretical double-evaluation divergence, raised then largely refuted — recorded so you can
    decide, not to alarm you.
    For a non-super-user, verifyPermsAST runs the full table check
    regardless of tokenOperations, and the export path now checks the statement twice: once in
    chooseOperation, once in the worker after the forced re-parse. Those runs read different state,
    because jobProcess.ts snapshots global.hdb_schema before loading built-in components. A
    reviewer raised this as a possible regression: a role exporting a component-defined table passing
    at dispatch and being denied in the worker. Four independent adjudication passes then went
    looking for a concrete failing case; three traced the live registry and persisted table metadata
    and found none, one retained it as conditional.
    So the divergence is real in principle and no
    one could construct it in practice — component-defined tables are in the persisted schema the
    worker snapshots. Ruled by the author: ship declared. The candidate fix reorders job worker
    startup — a different change with its own blast radius — and three of four passes failing to
    construct the failure is meaningful evidence against trading a hypothetical regression for a real
    one in job startup. Recorded rather than buried because it is the one place this change could
    plausibly break a working non-super-user export; if a reviewer disagrees, the fix is one line in
    jobProcess.ts.

  2. The carrier is request.operation — a body-derived field, which the ordering note on this PR
    explicitly ruled out. Please rule on why this instance is different.
    The note's constraint was
    "a carrier a client cannot set", because on the direct-SQL path the body is client-supplied and
    the AST check is the only gate. request.operation is body-derived, but in the job worker it is
    the same field serverUtils.getOperationFunction(request) reads to select the handler that then
    runs. Forging it therefore changes which operation executes, not which policy is applied to a
    different one
    — the two cannot diverge. That is the whole security argument, and it is why a
    new field (api_operation, tried and reverted in feat: OIDC trusted publishing — deploy from CI with no stored credential #2173) is unsafe while this one is not. If you
    don't accept it, the design changes.

  3. A second finding, not in the original scope: permissions_checked was forgeable, at two
    layers.
    evaluateSQL
    trusts a supplied parsed_sql_object verbatim and skips parsing; chooseOperation overwrote only
    the top-level one; dataLayer/export.ts hands the nested search_operation straight to
    evaluateSQL. So a request carrying search_operation.parsed_sql_object with
    permissions_checked: true would have run an arbitrary AST with the newly-live check skipped —
    i.e. this PR would have advertised a gate that a request body could switch back off. Now deleted
    at dispatch, forcing a re-parse of the sql string that dispatch authorized. This is the same
    defect as fix(security): authorize the invoked operation against the authenticated principal #2217's item 3; whichever lands second should drop its copy.

    What this is, precisely: a per-field allowlist, not an invariant. It closes every path that
    exists today — evaluateSQL:40 is the only consumer, and the only client-reachable objects handed
    to it are the top-level request and search_operation — but the next evaluateSQL caller that
    forwards a client object defeats all three deletes silently. Ruled by the author: keep the
    deletes here, move the invariant in a follow-up
    evaluateSQL should not honor a caller-supplied parsed_sql_object without a trusted dispatch marker #2304 proposes honoring a supplied
    parsed_sql_object only under a trusted dispatch marker in async context, reusing the mechanism
    this PR introduces. Deliberately not bundled: it changes a hot path and deserves its own review
    rather than being added to a change already six rounds deep. The dispatch-time strip
    alone is not sufficient
    , which is the second layer: the worker re-enters from the persisted
    hdb_job row rather than re-dispatching, so a job queued before this ships — or a row written
    directly — would still carry a forged object. jobProcess.ts therefore repeats the delete when it
    loads the row.

  4. One store with two fields, and the carrier survives a bypass. I first had it dropped, on
    the reasoning that checks reading it don't run under a bypass — true of the bypassed branch, false
    of the enforced one, which a job handler reaches when it dispatches a nested authorized operation.
    Preserving it fixes a spurious mid-job 403; the consequence to accept is the other direction, that
    such a nested dispatch is judged against the outer job's operation for any evaluateSQL not
    passing through chooseOperation. One-line flip either way, and the semantics choice is the
    security argument.

  5. getJobsInDateRange takes the bypass, and the reason is a trap worth knowing. I first
    switched it to the carrier on the grounds that a bypass also drops table checks and the carrier
    would "fail closed if the handler is ever opened wider". That was wrong: verifyPermsAST's
    super_user early return is isSuperUser && !isSuSystemOperation, so a system schema is
    exempt and the table check really does run. The carrier would therefore have put Harper's own
    query through hasPermissions on system.hdb_job, passing only because
    appendSystemTablesToRole grants system.*.read to a hydrated super_user — a super_user
    principal without an appended permission.system (an impersonation payload, or any path skipping
    user-cache hydration) would have started getting 403s. Reverted to the bypass, which is also the
    honest statement of intent: the query is Harper's, not the caller's.

  6. evaluateSQL's error contract is preserved, not fixed. It drops its second callback argument
    on error, so a denied job's durable message is the bare string 403 with no reason. Deliberately
    unchanged — every caller shares that contract — but denied jobs are consequently hard to
    diagnose. Happy to fix separately.

  7. The nested-SQL dispatch branch is still scope+AST only. Being mutually exclusive with
    verifyPerms, it does not enforce export_local's requires_su when
    search_operation.operation === 'sql'. Pre-existing and adjacent; closing it changes
    authorization outcomes, so it is flagged rather than bundled. fix(security): authorize the invoked operation against the authenticated principal #2217 approaches it from the other
    side.

Verification

  • The tripwire is answered. admits an in-scope export job through the path export.ts actually dispatches (unitTests/security/tokenOperationScope.test.js) — planted to go red when this lands
    — now asserts the fixed behaviour via the carrier, alongside two new cases pinning the other
    direction (a sql-only scope cannot start an export; no carrier fails closed).

  • Fails-on-base. With the guard reverted: 6 failing / 37 passing; with it, 43 passing.
    The 6 include the pre-existing GHSA-7h8h-wq7f-qx65 bypass test, which passed on base only because
    it stubbed ['denied'] — an array has length, so it satisfied the broken guard. Its stub now
    uses a real PermissionResponseObject. The parsed_sql_object strip was separately confirmed to
    fail on base.

  • Unit gates, per directory (the whole-suite test:unit:main invocation is not usable locally —
    see below). Every number below is on the final tree:

    this branch clean origin/main
    security 663 ✓ / 2 fail 661 ✓ / the same 2
    server (all subdirs) 744 ✓ / 1 fail serverHelpers 297 ✓ / the same 1
    sqlTranslator · sqlEngine 67 ✓ · 103 ✓
    dataLayer · validation · utility(ex-logging) 242 ✓ · 242 ✓ · 517 ✓
    agent · bin · config · install · build-tools · buildTools 87 · 239 · 248 · 9 · 16 · 13 ✓
    upgrade · root 19 ✓ · 8 ✓
    utility/logging stalls stalls too
    components 4 fail 14 fail (superset, name-identical)

    Every failure and the stall reproduce on clean main with none of this branch's code present.
    utility/logging hanging on main is what makes test:unit:main unusable here: .mocharc sets
    --timeout 0, so it goes quiet instead of failing. components cannot be attributed by rerun —
    its suite kills the process group of its own invoker — so it is attributed by reachability
    instead: nothing under components/ or unitTests/components/ references any module this PR
    touches.

  • Review coverage decayed across rounds, and the legs that went dark are the ones that caught my
    worst errors.
    Four rounds. codex (opposite-family) and the Harper domain adjudicator ran all
    four. Gemini ran round 1 only (round 2 died on a denied permission prompt, rounds 3–4 on
    quota). cursor-composer ran rounds 1–2 then hit the branch's Cursor round cap. So rounds 3 and
    4 were two lenses, and round 3's delta contained an authorization revert. cursor-composer is what
    caught the false verifyPermsAST premise and the persisted-row exposure. Four separate rounds
    corrected a confident claim of mine in this area; the pattern each time was prose asserting
    something the code did not do. Weigh my summaries here accordingly and read the carrier argument
    yourself.

  • Six rounds ran; the machine footer below reads ran=none and that is misleading in the strict
    direction.
    The receipt is at 273de2aa (codex + harper-domain, Adjudicated-Severity: minor,
    Human-Review-Need: 3). The only commit since is a DESIGN.md paragraph rewrite — no production
    code, no tests. I chose to fix the documentation and lose the matching receipt rather than ship a
    DESIGN section I knew was wrong, because DESIGN.md is the artifact future readers rely on.

  • Rounds 4–6 changed no production logic at all. They corrected documentation that had drifted
    from the code: a paragraph stating the inverse of the carrier's behaviour, another asserting the
    super_user early return applies to system schemas, and a third conflating three different
    mechanisms all called "scoped token". The code converged at round 3; my description of it took
    three more rounds.

  • One review conclusion I did not accept. Round 6 dropped the "jobs.ts bypass has no coverage"
    finding on the grounds that northwind.test.mjs's Search Jobs by date would go red without it. I
    traced it: that test runs as a hydrated super_user, for whom appendSystemTablesToRole grants
    system.*.read, so hasPermissions passes either way; and the adjacent non-super-user case is
    refused at verifyPerms before any SQL. The gap flagged in rounds 3–5 is real, and the claim in
    this description stands.

  • Planning review cleared Framing-Verdict: chosen-approach-sound before implementation.

Complexity: complicated

Review-Coverage: authored=unknown; ran=none; rounds=1 @ d817c12

Human-Review-Need: 4 @ d817c12

dawsontoth added a commit that referenced this pull request Aug 18, 2026
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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request fixes a critical authorization bypass bug in sqlTranslator/index.ts where permission denials were being discarded because the code checked for a .length property on a PermissionResponseObject (which does not exist, causing the check to always evaluate to false). The fix replaces this check with a simple truthiness check on permissionsCheck. Additionally, a comprehensive suite of unit tests has been added in unitTests/sqlTranslator/processASTPermissions.test.js to verify the correct behavior of processAST under various permission scenarios. There are no review comments, and we have no additional feedback to provide as the changes are correct and well-tested.

@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

dawsontoth added a commit that referenced this pull request Aug 18, 2026
…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>
@dawsontoth
dawsontoth marked this pull request as draft August 18, 2026 17:07
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Moving this to draft. It is a real pre-existing defect and the fix stands, but it touches shared SQL authorization for every caller, not just the OIDC feature, so it needs the full harper-engineering-guidelines treatment on its own — cross-model reviews with the coverage reported in the description — rather than inheriting #2173's. That will be picked up separately.

Nothing in #2173 depends on this landing: its own gate refuses the export/write combination at the front door, and it no longer attempts to carry a job's operation into the branch this fixes.

One thing for whoever picks this up: making the processAST branch live means a job's SQL will be authorized as sql rather than as its own operation (export_local), so a token scoped only to the job operation would be denied by its own job. Carrying the real operation on the request body was tried in #2173 and reverted — on the direct-SQL path that body is client-supplied and this check is the only gate, so any property it consults is forgeable. That carrier needs to be something a client cannot set.

🤖 Generated with Claude Code

dawsontoth added a commit that referenced this pull request Aug 18, 2026
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>
dawsontoth added a commit that referenced this pull request Aug 18, 2026
…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>
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Ordering note for whoever picks this up — #2173 now carries a test that will go red when this lands, deliberately.

Making the processAST branch live means a job's SQL is authorized as sql rather than as its own operation, because checkASTPermissions falls back to jsonMessage.operation and at that call site jsonMessage is the nested search_operation. So a token scoped to export_local would be denied by its own export job. Verified by applying this PR's diff on top of #2173 and rebuilding.

The failing test is admits an in-scope export job through the path export.ts actually dispatches in unitTests/security/tokenOperationScope.test.js. The fix is not to relax the scope check or delete the test — it is to give the job's real operation a carrier a client cannot set. A request-body property is not one: that was tried in #2173 and reverted, because on the direct-SQL path the body is client-supplied and that check is the only gate, so any property it consults is forgeable.

The two PRs can merge in either order; whichever is second goes red rather than silently shipping an export-scoped token that 403s on its own export.

🤖 Generated with Claude Code

dawsontoth added a commit that referenced this pull request Aug 18, 2026
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>
dawsontoth added a commit that referenced this pull request Aug 19, 2026
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>
dawsontoth added a commit that referenced this pull request Aug 19, 2026
…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>
dawsontoth added a commit that referenced this pull request Aug 19, 2026
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>
dawsontoth added a commit that referenced this pull request Aug 19, 2026
…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>
dawsontoth added a commit that referenced this pull request Aug 19, 2026
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>
kriszyp pushed a commit that referenced this pull request Aug 21, 2026
…ial (#2173)

* feat(security): OIDC identity token verification for trusted publishing

Core of #2171: verify a CI identity token against an issuer's published
signing keys and match it to a trust policy's claim constraints. No
storage or operation wiring yet — this is the layer those sit on.

security/oidcTrust/claims.ts is pure: claim normalization (deriving
workflow_path from workflow_ref so a tag release can pin the workflow
file without knowing the tag), exact/any-of matching that denies on an
absent claim, and write-time validation requiring a repository pin, a
workflow pin, and a ref-or-environment gate. That last requirement is
what npm's repository+filename model lacks: without it, anyone who can
push a branch can add the trusted workflow to it and mint a token.

security/oidcTrust/jwks.ts fetches keys with the conservatism the
unauthenticated exchange endpoint demands: https only, bounded body and
time, discovery-issuer cross-check, asymmetric keys only, concurrent
loads collapsed, and a rate limit on the refetch an unrecognized kid
triggers. The rate-limit clock is kept outside the cache entry so a
successful fetch does not reset it — and so a genuine key rotation is
picked up on first use rather than after the window.

security/oidcTrust/index.ts verifies signature, issuer, and audience,
and additionally requires exp, a bounded lifetime, and jti (a token that
cannot be identified cannot be replay-protected). Rejection reasons go
to the log, never to the caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(security): hdb_oidc_trust table and trust policy operations

Storage and administration for #2171: add_oidc_trust / list_oidc_trust /
drop_oidc_trust over a new system.hdb_oidc_trust table, following the
three-touchpoint pattern DESIGN.md documents for a new system table
(schema entry, SYSTEM_TABLE_NAMES, upgrade directive). The directive is
tagged 5.3.0 to match the release that ships these operations — a later
tag would never fire on the upgrade path and leave the table missing.

A policy names a Harper user and deliberately carries no operation
allowlist of its own: least privilege is that user's role, and a second
authorization mechanism running alongside roles is one more place for
the two to disagree.

Notes for review:

- add_oidc_trust rejects an issuer's default audience (https://github.com/<owner>),
  which every repository under an owner shares. Accepting it is the one
  configuration mistake that makes the audience check meaningless.
- User existence is checked against the users cache, not
  findAndValidateUser: with validatePassword false that returns a bare
  { username } for an unknown user, so it cannot answer the question.
- Handlers enforce super_user directly as well as via requiredPermissions,
  matching secretOperations — a role's `operations` allowlist can
  otherwise delegate an SU-only operation.
- Naming a super_user returns a warning rather than an error. An admin may
  mean it; it just should not be silent.
- The ops join secrets in the MCP DEFAULT_EXCLUDED set. list_oidc_trust
  matches the `list_*` glob, and the policy set names exactly which
  repository and workflow are worth compromising.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(security): exchange_oidc_token — mint a token from a CI identity

Completes the server half of #2171. A runner posts its provider's
identity token; if it verifies against an enabled trust policy, Harper
returns a one-hour operation token for the user that policy names. The
operation is unauthenticated because it *is* the authentication, the
same way create_authentication_tokens is against a password.

createOperationToken is new in tokenAuthentication.ts because
createTokens could not be used: it overwrites hdb_user.refresh_token as
a side effect, so minting for CI would silently revoke whatever
credential that user already held (#2018) — the exact problem this
feature exists to remove.

Notes for review:

- Every rejection returns the same message and status; the reason goes
  to the log. The endpoint is unauthenticated, so a caller told which
  check failed can enumerate a policy one claim at a time.
- Replay: hdb_oidc_token_use records issuer|jti with expiresAt set past
  the token's own expiry, so it stays proportional to in-flight tokens.
  The get-then-put is not atomic and does not pretend to be — see the
  comment on getTokenUseTable for why the concurrent race is tolerable
  (it is not a privilege escalation) and what it does stop.
- The use is recorded *before* minting. A failure after recording costs
  a CI re-run; the reverse ordering would leave a spendable token behind.
- The user is resolved before the token is spent, so a policy naming a
  deleted or deactivated user fails without burning a token the runner
  cannot re-mint. Deactivating a user stops its workflows.
- Policy selection iterates enabled policies for the token's issuer,
  first match by id. Signature verification is memoized per audience so
  N policies sharing one audience cost one verification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(cli): exchange a CI identity for a Harper token automatically

Completes #2171. On a runner that offers an OIDC identity token, the CLI
asks the provider for one addressed to this instance and trades it via
exchange_oidc_token — so a GitHub Actions deploy needs `id-token: write`
and a target URL, and no secret at all.

Ranked below every configured credential (env tokens, saved login), not
above. Adding `id-token: write` to a workflow that still sets
HARPER_CLI_REFRESH_TOKEN must not silently change which identity
deploys; the ambient credential is the fallback, not the override.

Notes for review:

- The audience sent to GitHub is the resolved target, not the provider
  default — that default is shared by every repository under an owner,
  and is what makes a token replayable at an unrelated service.
- Detection requires BOTH ACTIONS_ID_TOKEN_REQUEST_URL and _TOKEN.
  Their absence means the workflow did not grant `id-token: write`,
  which is an answer rather than a failure to report.
- Failures are reported and swallowed. This is the last credential
  source before the request goes out unauthenticated, and the resulting
  401 says nothing useful, so a 401 from the exchange prints what the
  operator can actually inspect (list_oidc_trust, the audience).
- Local (no-target) operations never reach the exchange, same as the
  env-var tokens: bypassLocalAuth only applies with no Authorization
  header, so attaching one opts out of the domain socket's trust.

DESIGN.md gains a section on the layering and the three constraints that
look like choices but are not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(security): cut comments the code can carry itself

Self-audit pass. No behavior change — 196 tests unchanged and green.

Structural, not just prose:

- claims.ts folded three copy-pasted structural checks into one
  STRUCTURAL_REQUIREMENTS table. The table shows the symmetry that a
  paragraph previously had to assert, and the three exported constant
  arrays it replaces were exported but imported nowhere, not even by
  tests.
- Deleted describeUnpinned(), a wrapper around Array#join whose name
  described something it did not do.
- listOidcTrust and loadEnabledPolicies were the same scan-toRecord-sort
  with one filter differing; both now call readPolicies(includeDisabled).
- rejectToken moved to identityToken.ts and is shared with the exchange,
  replacing a second near-identical rejectExchange — and with it the
  fourth copy of the "reason goes to the log, not the caller" rationale.
- findMatchingPolicy lost a try/catch and a `void error` to a .catch().
- security/oidcTrust/index.ts is now identityToken.ts. It was never a
  barrel, so `from './index.ts'` misdescribed what siblings were
  importing; the name now matches its test file.

Three rationales were each told in three or four files (audience must be
instance-specific, rejection reasons stay in the log, least privilege is
the named user's role). Each now has one canonical site with the code
that enforces it, and pointers elsewhere.

Across security/oidcTrust/ plus bin/ciIdentityToken.ts: 1040 -> 939
lines, of which comment lines 310 -> 225 (29% -> 23%).

What stayed is the non-obvious: why splitting on `@refs/` rather than
`@`, why the rate-limit clock sits outside the cache entry, why
createTokens cannot be used here, and why ref_type is not a ref gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(security): keep identity and refresh tokens out of the operations 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>

* refactor(security): make the OIDC core issuer-agnostic, GitHub a profile

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>

* test: remove the JWT keys the exchange suite writes, and share the helper

tokenExchange.test.js wrote real signing keys into the test base path and
never removed them. That path is shared:
unitTests/utility/install/checkJWTTokensExist.test.js asserts those files
are ABSENT (its happy path expects accessSync to throw ENOENT), and mocha
runs every file in one process against one base path — so whichever ran
first decided whether the other passed.

It has been latent here. It surfaced on the stacked branch (#2174) when a
second file started writing the same keys, failing that suite on all
three Node versions; the same landmine was already sitting on this branch
waiting for a file-order change.

The fix is a testUtils.installTestJwtKeys() that returns a cleanup
function, so the next test needing signing keys gets the removal for free
rather than copying the setup and not the teardown. Verified by running
the exchange suite and checkJWTTokensExist together, which failed before
and passes now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(security): narrow a minted token to a subset of its user's operations

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>

* fix(security): carry an empty operation scope instead of dropping it

Review feedback from gemini-code-assist on #2174; all four points were
correct.

The one that mattered: `createOperationToken` gated the claim on
`user.operations?.length`, so an EMPTY scope — meaning "no operations" —
was omitted from the payload entirely. The minted token then looked
unscoped, verifyPerms skipped narrowing, and the holder got everything
its role allowed. A security control failing open, and in the one
direction that matters.

add_oidc_trust rejects an empty array (Joi .min(1)), so this is not
reachable through the documented API. It is reachable by a row arriving
through replication from a peer, which is the same path
matchTrustPolicyClaims already backstops against — a control must fail
closed regardless of how the input got there.

Also:

- verifyPerms used `!== undefined` where an unscoped policy stores
  `operations: null`; expanding null would throw rather than fall
  through to the role. Now `!= null`, which is also the repo's
  documented idiom (.gemini/styleguide.md).
- Operation-name validation delegates to validateOperations instead of
  a local OPERATIONS_ENUM check. That helper also accepts operations
  registered at runtime via server.registerOperation, which the local
  check would have rejected — so a policy could not scope to a
  dynamically registered op.

Three tests added, one per failure mode. The empty-scope test is a
round trip through createOperationToken rather than a verifyPerms unit
check: the existing suite passed `[]` straight to verifyPerms and denied
correctly, which is exactly why it missed a mint path that never emitted
the claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(security): apply the token operation scope to the SQL path too

Caught in review by claude[bot] on #2174, and it falsified the PR's
central claim.

chooseOperation dispatches `operation === 'sql'` to verifyPermsAST, in a
branch mutually exclusive with the verifyPerms call — and the narrowing
gate lived only in verifyPerms. So a token scoped to, say,
`operations: ['get_status']` could send {"operation":"sql","sql":"DELETE
FROM ..."} and run arbitrary SQL against whatever its role could reach.
verifyPermsAST also returns null unconditionally for a super_user, so the
identity most needing the constraint was the least constrained.

The gate is now a shared tokenScopeDenial() called first by BOTH entry
points, rather than a second copy in verifyPermsAST. The lesson of the
bug is that a check living inside one of two mutually exclusive branches
is one refactor away from being skipped, so the comment enumerates all
three early-return paths that bypass it if it ever moves.

On the AST path the scope is checked against `sql` — the operation the
caller actually invoked — because verifyPermsAST's `operation` parameter
is the statement variant (select/insert/...), not the API name. It runs
ahead of AST parsing as well as the super_user bypass: an out-of-scope
request should not get its SQL parsed at all.

Five tests on the SQL path. Verified they fail without the fix (2
failing) and pass with it, so they pin the hole rather than describing
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(security): carry the token operation scope across credential minting

A cross-model review (codex + gemini) found a fourth bypass of the token
operation scope, the same authz-escape class as the earlier SQL-path
hole: the scope is only enforced inside verifyPerms/verifyPermsAST, but
three operations PRODUCE a new credential or principal and dropped it.

- create_authentication_tokens (the headline path): it is in
  NO_AUTH_OPERATIONS, so verifyPerms — and the scope gate inside it —
  never runs. A token scoped to e.g. deploy_component could call it with
  no username/password and receive fresh, UNSCOPED operation + refresh
  tokens for its own user: full-role escalation.
- refresh_operation_token: dropped the operations claim when re-signing.
- impersonation: enforceDowngrade bounds the impersonated role's perms
  but shed the token scope, so a scoped super_user token could drop the
  scope by impersonating.

Fix: the scope carries forward on all three surfaces, so a scoped
credential can only ever mint/become an equally-scoped one — the same
"can only subtract" invariant, extended to the paths that leave
verifyPerms. createTokens and refreshOperationToken copy the caller's
scope into the minted payload; applyImpersonation copies it onto the new
principal.

Also moved the api_name resolution into tokenScopeDenial so the unscoped
default path (every non-scoped request) does no registry lookup before
its `== null` return, and updated the helper's comment to enumerate this
fourth bypass class alongside the three in-function ones.

Tests: createTokens carries the scope into both minted tokens and stays
unscoped for an unscoped caller (reusing the existing mocked suite);
refresh_operation_token preserves the scope through a real
validate->decode->sign round trip; impersonation carries it onto the
impersonated user. 253 passing across the affected suites.

Adjudication of the rest of that review is in the PR description. Two
flagged "blockers" were false positives (an undefined `op` — `op` is
declared and the scope tests exercise that exact line; and a
non-existent alter_oidc_trust operation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): deny a scoped token from minting a login token

Fifth bypass of the token operation scope, found by claude[bot] on the
(now-folded-in) #2174 review — same class as the create_authentication_tokens
escalation: a path that produces a new credential and drops the scope.

create_authentication_tokens with purpose:'login' is NO_AUTH, so the
scope gate in verifyPerms never runs. Its login branch signs a
username-only token, which the `login` operation trades for a cookie
session — and a session is username-only by construction: session-restore
reloads the FULL user via getUser (tokenOperations is only ever set from a
JWT operations claim, never on a session-restored user). So a credential
scoped to e.g. deploy_component could self-escalate to a fully unscoped
session with two NO_AUTH calls, no password required.

A session cannot carry an operation scope, so carrying it forward is not
possible without reworking the session model; a scoped CI/OIDC credential
has no use for a browser session anyway. Fix: deny purpose:'login' when
the authenticating caller is scoped (reuses the inheritedScope already
computed for the operation/refresh path just above).

Tests: a scoped caller is refused (403); an unscoped caller still mints a
login token (and no refresh token, as before).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(security): extract the token-scope carry-forward into one helper

The operation scope had to be threaded through every path that produces a
credential or principal — six sites, each re-inlining `Array.isArray(...)`,
and one using `!= null` instead. That scatter is exactly why the five
bypasses this feature closed turned up one at a time.

`security/operationScope.ts` is now the single home for the guard:
`hasOperationScope` (the predicate), `attachScopeToToken` (the `operations`
claim on a payload), and `attachScopeToUser` (`tokenOperations` on a user
principal). The six call sites — createTokens, its login-deny, refresh,
createOperationToken, validateToken, and impersonation — each collapse to
one self-documenting call, and the `!= null` outlier is normalized to the
same array guard (behavior-identical: an empty deny-all scope is still
carried, anything non-array still skipped).

No behavior change — the module carries the same array-including-empty
guard every site already used, verified by the full scope-path suite
(createTokens scoped/unscoped/login-deny, refresh, impersonation,
createOperationToken empty/absent, validateToken) plus 8 unit tests for
the helper itself. The naming asymmetry (`operations` on tokens vs
`tokenOperations` on users) is now documented once, in the module.

Beyond making the diff tighter, this is the thing that makes the invariant
maintainable: a future credential-producing path is one `attachScope*`
call, and greppable rather than a pattern to remember.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): gate the token scope on the API operation, not the handler name

Two findings from the combined cross-model review, one root cause. The
scope gate resolved the operation name from the handler function
(`requiredPermissions.get(op)?.api_name ?? op`) instead of using the API
operation the caller actually sent — the namespace the policy scope is
written in.

- deploy_component (#481, the headline use case): its handler is
  registered with no api_name, so the gate resolved `deployComponent` and
  a policy scoped exactly to `deploy_component` was DENIED. The feature
  did not work for the operation it exists to scope. Fail-closed, so not
  an escalation — but functionally dead. Shared handlers
  (search_by_id/search_by_hash) were also conflated.
- nested-SQL export jobs (#506): verifyPermsAST hardcoded `sql` as the
  scoped operation, but export_local/export_to_s3 carry their query as
  SQL through the same branch. A token scoped only to `sql` could start
  an export it was never granted, because the gate never saw
  `export_local`.

Fix: verifyPerms passes `requestJson.operation`; verifyPermsAST takes the
top-level API operation (threaded from checkASTPermissions'
`jsonMessage.operation`, defaulting to `sql`); tokenScopeDenial compares
that directly against the scope and no longer reconstructs a name from
the handler. Using the real operation also distinguishes shared-handler
aliases for free.

Tests: deploy_component is allowed when scoped and denied when not;
search_by_id vs search_by_value are distinguished though they share a
handler; a `sql`-only scope cannot start an export_local job while an
export_local-scoped one can.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): gate the token scope on the job op for non-SQL export jobs too

Follow-up to the previous commit, found by claude[bot]: I fixed the
export-job scope bypass on the SQL path (verifyPermsAST) but left the
identical hole on the NoSQL path.

The dispatcher hands verifyPerms the nested search_operation as
requestJson for a job, so requestJson.operation is the inner op
(search_by_conditions, search_by_value, ...), not export_local. The
scope gate therefore checked the read op: a token scoped to
['search_by_conditions'] passed, then the super_user bypass returned
allowed, and the export ran — writing exported data to local disk or S3
from a credential meant to be read-only. Same "can only subtract"
violation as #506, via the NoSQL search operations.

Fix mirrors the verifyPermsAST one: serverUtilities threads the
top-level json.operation into verifyPerms via options.apiOperation, and
the scope gate uses `options?.apiOperation ?? requestJson.operation`, so
the job op is checked while direct callers keep using requestJson.operation.

Test: a search-scoped token cannot run an export_local job whose nested
op is search_by_conditions; an export_local-scoped one can.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): drop an unused path import left by the rebase conflict resolution

The rebase onto main's deploy-setup change collided in the cliOperations
import block; resolving it kept `import * as path from 'path'`, but the
merged file no longer uses path. Removes the unused import (oxlint
no-unused-vars).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): harden token scoping per deep-review (4 findings)

A single-pass Harper-domain deep-review of the combined PR (the lens the
cross-model run's domain leg failed to execute) surfaced four issues.

1. Scoped token could mint a long-lived credential (significant).
   create_authentication_tokens is NO_AUTH, so the scope gate never runs.
   The login path already denied scoped callers, but the standing
   operation+refresh path carried the scope forward yet honored expires_in
   verbatim and wrote a refresh_token — turning a minutes-long leak into a
   decade-long one (scoped, so not privilege escalation, but it defeats the
   exchange's ephemerality guarantee), reachable even by a deny-all scope.
   Now a scoped caller is denied outright (covers login + standing paths);
   a CI token holds the operation token the exchange already gave it.

2. Scope guarantee overstated in types.ts (doc). The scope is enforced on
   the operations-API and SQL paths only (verifyPerms/verifyPermsAST); the
   REST/GraphQL resource path authorizes via table-level checkPermission and
   doesn't consult it. Narrowed the doc and pointed resource-path enforcement
   at the CORE-3061 follow-up surface. NOT enforcing it there in this PR.

3. Replay table not audited (significant). hdb_oidc_token_use was created
   without an explicit audit flag, so with logging.auditLog:false its rows
   never replicate — silently dropping cross-node replay protection while
   the trust policies that gate it still propagate. Now audit:true, matching
   its sibling hdb_oidc_trust. (Kept lazy table() rather than the systemSchema
   bootstrap because the expiresAt TTL is not expressible via CreateTableObject;
   this matches hdb_certificate_cache.)

4. Exchange trusted stored rows to be write-validated (suggestion).
   add_oidc_trust enforces assertPolicyIsSpecific, but the exchange only
   backstopped the empty-claims and pull_request_target cases. A row that
   arrived via replication from an older node or a restored backup —
   e.g. repository pinned, no workflow/ref gate — was honored. findMatchingPolicy
   now re-runs assertPolicyIsSpecific/assertAudienceIsSpecific and skips
   (logs) any row that fails. Fail closed.

Tests: scoped caller denied on standing/deny-all/login paths with no
user-record write; an under-specified stored row is ignored at exchange.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): close credential-minting, scope, and lifetime gaps from review

Two independent model reviews (Codex and Claude) converged on four of these.

1. create_authentication_tokens could still mint from an exchanged token. The
   previous guard fired only on a SCOPED caller, but a trust policy carries
   `operations` only when the operator opts in — so the ordinary exchanged
   token is unscoped and sailed through, taking a caller-controlled expires_in
   and a 30-day refresh token with it. Mint provenance is now a signed claim on
   every token createOperationToken produces, lifted onto the principal at
   validateToken and refused ahead of the user lookup, so a refused request
   reads nothing and writes nothing. Impersonation carries it forward too:
   it returns a new principal, which would otherwise launder the marker.

2. A `read_only` scope could run write SQL. The group expands to include `sql`,
   and verifyPermsAST returns null for a super_user before any table check, so
   DELETE/UPDATE/INSERT passed. A write statement now additionally requires its
   matching data operation in scope — which is exactly what separates read_only
   from standard_user, with no need to track which group admitted `sql`.

3. job_workflow_ref no longer satisfies the caller-ref gate. It names the
   reusable workflow that ran, not the caller that invoked it, so its @ref is
   constant however it is called and admitted any branch of any caller repo
   referencing that workflow. It remains valid as a workflow pin.

4. Identity tokens now require `iat` and bound `exp` against the verification
   clock. The ceiling was skipped entirely when `iat` was absent, and a pair
   shifted equally far into the future kept a small delta while staying valid
   for as long as it liked.

Also corrects DESIGN.md, which asserted the opposite of the implemented
per-policy allowlist, and records the REST/GraphQL enforcement boundary there
rather than only on the types.ts field. The new createTokens cases drop the
rewire mutations AGENTS.md prohibits — reachable now that the guard runs before
any I/O — and the two positive-path cases they came with were already covered.

Documents, rather than works around, a pre-existing gap in the shared grantable-
operation registry: it is process-local and the OPERATION_REGISTERED bridge
carries only name/thread routing, so add_role, alter_role, and impersonation
validation reject worker-registered component operations on main identically.
It fails closed, and the fix belongs to that bridge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): key replay on the signed input, and fix issuer/audience matching

Third cross-model review (Barber AI). The replay finding is a real bypass and
the premise it rests on was mine.

1. HIGH — replay protection was bypassable. The fingerprint hashed the whole
   token on the stated grounds that "a replayed token is byte-identical by
   definition". That is false: the signature segment is covered by nothing, and
   base64url decoding ignores the surplus low bits of its final character, so an
   RS256 signature has 16 distinct spellings that decode to identical bytes.
   Verified against this branch's jsonwebtoken — all 16 verify, each hashing
   differently, so one leaked identity token bought 16 operation tokens. ES*
   malleability (s -> n-s) is a second such vector. Now keyed on the signed
   input (header.payload), which is exactly what the issuer asserted, so every
   re-spelling collapses to one fingerprint. The regression test was confirmed
   to fail against the old fingerprint before being kept.

2. An issuer whose `iss` ends in `/` could never authenticate: the expectation
   passed to jwt.verify is normalized, the comparison is byte-for-byte. Azure AD
   v1 emits exactly that, and the generic profile exists to serve such issuers
   with no provider code. Both spellings are accepted now, which cannot widen
   trust — normalizeIssuer already collapses them for the cache key, the
   discovery check, and the policy lookup.

3. `audience` was stored raw while `issuer` was normalized, but the CLI requests
   its token for normalizeTarget(target) — port and trailing slash included. So
   the natural `audience=https://host` stored a policy that could never match,
   failing opaquely in CI. Rejected at write time now, where the administrator
   can see it. Rejected rather than canonicalized: silently rewriting a value
   whose job is byte-for-byte comparison is worse, and canonicalizing ahead of
   assertAudienceIsSpecific would disarm the shared-audience guard, since
   https://github.com/<owner> normalizes out of that regex. A test pins the
   check to normalizeTarget's real output so the two cannot drift.

Also: audit records the x-forwarded-for client rather than the proxy, matching
auth.ts; auditing can no longer change the outcome it records (a throwing
success emit was caught and re-reported as a failure); JWKS/network errors are
logged instead of vanishing into a misleading "no policy matched"; a missing
trust table no longer answers an anonymous caller with a descriptive 400 that
breaks the uniform-rejection property; the issuer filter moved into the scan so
an unauthenticated request no longer allocates a record per stored policy; the
exchange uses the standard CLI timeout rather than inheriting deploy_component's
10-minute SSE timeout; a blank-but-set token namespace no longer falls through
to workload identity, which would deploy a failed CI secret as a different
identity; clearJwksCache can no longer be undone by an in-flight fetch; and two
JSDoc references to symbols that never existed now name the real ones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): bootstrap the replay table and carry the job's real operation

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>

* fix(security): reject a non-boolean `enabled`, and pin exact claim matching

`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>

* fix(security): act on the SQL permission denial processAST was discarding

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>

* refactor: split the processAST guard fix out to its own PR (#2202)

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>

* fix(security): never read the SQL scope's operation from the request 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 11f2280c6, 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>

* fix(upgrade): patch is_hash_attribute on the replay table; pin the enabled 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>

* docs(upgrade): record the TTL-on-first-use limitation for the replay 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>

* test: assert the export-job scope gate enforces, not just computes

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>

* test: make the #2202 ordering constraint fail loudly instead of living 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>

* test: decouple the #2202 tripwire from the status literal it watches

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>

* fix(security): refuse malformed stored policies; let exchange_oidc_token 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>

* fix(security): back off after a failed JWKS refresh; stop overclaiming 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>

* fix(security): apply the JWKS backoff to an issuer with no cached keys

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>

* fix(security): tell the truth in list_oidc_trust; pin the guards the 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>

* fix(security): report a deleted or deactivated policy user in list_oidc_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>

* test: pin the invalid_reason precedence a row with both problems relies 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>

* test: pin the stale-key grace ceiling with an injected clock

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>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the fix/process-ast-permission-guard branch from 7cc9249 to 894e9c9 Compare August 24, 2026 19:48
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Picked this up, and the branch has been force-pushed — the one-line guard fix plus a test file is now
the guard fix plus the carrier the ordering note
above asked for. Answering that note directly, since it set the constraint:

The tripwire fired exactly as predicted. admits an in-scope export job through the path export.ts actually dispatches went red on the one-line fix alone. It now asserts the fixed behaviour, with two
new cases pinning the other direction (a sql-only scope cannot start an export; absent a carrier the
gate fails closed).

The carrier is request.operation, which is body-derived — the note ruled that out, so here is why
this instance is different.
In the job worker, request.operation is the same field
serverUtils.getOperationFunction(request) reads to select the handler that then runs. Forging it
changes which operation executes, not which policy is applied to a different one; the two cannot
diverge. That is what makes it safe where a new field (api_operation, tried and reverted in #2173)
is not. It is carried on async context (runWithDispatchedOperation), never read back off the request
by the check itself.

One thing the note did not anticipate, and it mattered more than the carrier. Making the branch
live also made permissions_checked load-bearing — and it was forgeable. evaluateSQL trusts a
supplied parsed_sql_object verbatim and skips parsing; chooseOperation overwrote only the
top-level one; dataLayer/export.ts hands the nested search_operation straight to evaluateSQL.
So a request carrying search_operation.parsed_sql_object with permissions_checked: true would have
executed an arbitrary AST with the newly-live check skipped. In other words, without this the PR would
have advertised a gate that a request body could switch back off. Now deleted at dispatch and again
in the job worker when it loads the persisted row, because the worker re-enters from hdb_job rather
than re-dispatching. Same defect as #2217's item 3 — whichever lands second should drop its copy.

Two things deliberately left open, both in the description: evaluateSQL drops the denial object
on error, so a denied job records the bare string 403 with no reason; and the nested-SQL dispatch
branch still does not enforce export_local's requires_su, which #2217 approaches from the other
side.

And one honest gap. Nothing proves the worker actually establishes the carrier — delete that call
and CI stays green. A token operation scope originates only from an OIDC trust-policy exchange, and
there is no integration harness for it; table permissions cannot substitute (requires_su plus the
super_user early return), and an inline-role scoped token is minted super_user: false so it cannot
invoke a requires_su export at all. I wrote two integration tests, proved neither premise
reachable, and deleted them rather than leave tests that pass for the wrong reason. Filed as #2298.

The earlier bot reviews on this thread predate the rewrite and no longer describe the diff.

@dawsontoth
dawsontoth force-pushed the fix/process-ast-permission-guard branch 2 times, most recently from afcd943 to 273de2a Compare August 24, 2026 20:41
Comment thread server/jobs/jobs.ts
@dawsontoth
dawsontoth force-pushed the fix/process-ast-permission-guard branch from 273de2a to d817c12 Compare August 24, 2026 20:57
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Replying to @claude's inline suggestion on server/jobs/jobs.ts:255 here rather than in the thread — I have a pending review open, and GitHub's reply endpoint refuses a second one.

Agreed on the gap — independently confirmed, and it is declared in the description. Worth recording why neither suggested route closes it, since both look like they should:

A spy on runWithOperationAuthorizationBypass would need sinon, which AGENTS.md:196 prohibits for new tests ("do not add new uses of sinon or rewire… If you can't write a test without stubbing, comment on the issue describing what's missing and stop"). This PR already removed the sinon I had added to tokenOperationScope.test.js for that reason.

An unstubbed case with a restricted-scope hdb_user never reaches the query. search_jobs_by_start_date is requires_su, so a restricted principal is refused by verifyPerms in chooseOperation before any SQL runs — verified directly while attempting exactly this test: Operation 'handleGetJobsByStartDate' is restricted to 'super_user' roles. The principal that would exercise the bypass is a super_user without an appended permission.system (an impersonation payload, or a path skipping user-cache hydration), and the operations API cannot construct one.

Also flagging a related claim from another review round, in case it resurfaces: northwind.test.mjs's Search Jobs by date does not pin this wrapper. It runs as a hydrated super_user, for whom appendSystemTablesToRole grants system.*.read, so hasPermissions passes with or without the bypass; the adjacent non-super-user case asserts a verifyPerms denial that never reaches SQL.

The tractable option, if the author wants coverage here, is a sinon-free test pinning the fact the bypass depends on rather than the wrapper: assert that checkASTPermissions denies SELECT * FROM system.hdb_job for a super_user role carrying no permission.system, since verifyPermsAST's early return is isSuperUser && !isSuSystemOperation and a system schema is exempt from it. That guards the invariant a future refactor would break. Not adding it in this pass — the PR is parked in draft for a human ruling on a separate question, and I would rather not extend the diff underneath that.

Tracked with the other declared coverage gap in #2298.

Comment thread server/jobs/jobProcess.ts Outdated
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Replying to @claude's inline suggestion on server/jobs/jobProcess.ts:63 here — my pending review holds this PR's one-pending-review slot, so the thread reply endpoint refuses.

Accepted, and done: #2298 now covers this line too. The diagnosis is exactly right — jobProcess.ts is a top-level worker IIFE keyed off process.env, so both of its new branches (the load-time sanitize and the runWithDispatchedOperation carrier immediately below) are unreachable in isolation for the same reason, and whatever makes that file testable closes both at once rather than one.

Two clarifications recorded on the issue:

  • The dispatch-time twin is coveredserverUtilities.test.js's discards a body-supplied parsed_sql_object on the nested search_operation, confirmed to fail on base. So the primary path has a regression test; the worker-side repeat is defense-in-depth for a persisted or directly-written row, which is the part that is untested.
  • The two positions differ deliberately: chooseOperation deletes only the nested object because it overwrites the top-level one with its own parse — but only inside its SQL branch, so a non-SQL job (e.g. export_local with a search_by_value search) can still carry a client-supplied top-level object through to persistence. That asymmetry is why the worker deletes both.

Not adding a test in this pass: the PR is parked in draft for a human ruling on a separate question, and extending the diff underneath that would also invalidate the review receipt the description points at.

Comment thread server/serverHelpers/serverUtilities.ts Outdated
Comment thread server/jobs/jobProcess.ts Outdated
Comment thread sqlTranslator/index.ts
Comment thread DESIGN.md Outdated
Comment thread server/serverHelpers/operationAuthorizationState.ts Outdated
processAST computed a permission denial and discarded it: the guard read
`permissionsCheck.length > 0`, but checkASTPermissions returns null or a
PermissionResponseObject, which has no `length`. `undefined > 0` is always
false, so no denial reaching that branch was ever honored.

Making the guard live exposes two paths that pass the wrong API operation to
the token-scope check, because they re-derive it after dispatch:

- A job worker re-parses SQL out of its nested search_operation, so an
  export_local-scoped token was judged as `sql` and refused on its own
  export. jobProcess now carries the dispatched operation on async context,
  read from the same property that selected the handler.
- getJobsInDateRange runs Harper's own fixed hdb_job query through
  evaluateSQL beneath an already-authorized handler; that call now uses the
  existing trusted-internal bypass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cb1kenobi

Copy link
Copy Markdown
Member

Re-reviewed 13ee095f (was d817c124) — incremental range only: 7 files, +106/-19, derived from git range-diff against merge bases computed separately for each head (both resolve to 0d359723, so this is a force-push amend of the single commit, not a rebase).

Prior findings

  • Low — permissions_checked set on a denial (sqlTranslator/index.ts): fixed. The assignment now runs only after the verifyResult early return, so a denied AST can no longer read as already authorized.
  • Low — DESIGN.md enumerated two call sites : fixed, and the new enumeration checks out. runWithOperationAuthorizationBypass has exactly four production call sites (serverUtilities.ts:361, registeredOperations.ts:215, jobs.ts:255, sqlEngine/diff/differential.ts:42), and the search_jobs_by_start_datehandleGetJobsByStartDategetJobsInDateRange chain to jobs.ts:255 is real.
  • Nit — mutable carrier stores: fixed. Both dynamic stores are now Object.freezed; nothing anywhere writes to the store, so the freeze is safe as well as correct.
  • Medium — jobProcess controls uncovered: the suggested extraction landed. stripSuppliedParsedSqlObject now has a name, a module, and five tests, and that coverage is real — removing the nested delete turns 3 tests red. The jobProcess call sites remain uncovered: at this head, dropping stripSuppliedParsedSqlObject(request) and dropping runWithDispatchedOperation each still survive the full unit suite. Tracked in No end-to-end coverage that the job worker establishes the dispatched-operation carrier #2298, which is the right disposition given the worker-IIFE constraint.
  • High — export_local/export_to_s3 skip requires_su for a nested SQL search_operation: not addressed here, and this diff touches neither dataLayer/export.ts nor utility/operation_authorization.ts nor the branch structure, so it stands unchanged. Now tracked in export_local / export_to_s3 skip their requires_su gate when the nested search_operation is SQL #2305 — pre-existing and out of scope for this PR.

New work: no issues found.

Verification at this head: full unit suite 4801–4804 passing against 4788 at the merge base, with the head failure set a superset of the base's only by four spawn/keys tests that also differ between two consecutive clean runs (environmental flake, unrelated to this diff). All four regression mutations from the prior pass are still killed: restoring the dead .length > 0 guard (6 red), dropping the chooseOperation strip (1 red), dropping apiOperation from the carrier (4 red), dropping carrier preservation across the bypass wrapper (1 red). Prettier clean.

This PR looks good, nice job!


Generated by Barber AI

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work!
🤖 Reviewed with Codex

@kriszyp
kriszyp merged commit c05e5fa into main Aug 25, 2026
83 of 87 checks passed
@kriszyp
kriszyp deleted the fix/process-ast-permission-guard branch August 25, 2026 13:26
cb1kenobi added a commit that referenced this pull request Sep 3, 2026
…ted principal

chooseOperation handed verifyPerms the caller-supplied search_operation, making
both halves of the permission question — principal and tables — body-controlled.
Rebased onto main, which independently landed the SQL operations-allowlist check,
apiOperation token-scope threading (#2176, #2260), and the processAST denial fix
(#2202). This layers the principal/subject hardening on top and closes the gaps
the original PR had deferred:

- Principal comes from authentication: a nested hdb_user is overwritten, never
  backfilled.
- search_operation stands in as the permission subject only for the export
  operations that consume it, must be an object, and must name a supported export
  operation (search_by_value/hash/conditions/sql) — a primitive, {}, or an
  unsupported op is a request-time 400.
- One verifyPerms call cannot authorize both the outer export and its nested
  query: the outer op is authorized first, then the nested search is authorized
  additively against its real search handler and the authenticated principal, so
  a role granted export_local but not the underlying read is denied (previously
  granted at verifyPerms gate 2 before any table check).
- SQL export is routed through verifyPerms too, so its requires_su gate is
  enforced exactly as on the non-SQL path — SQL is no longer a way around it.
  (main's SQL branch checked only the allowlist + AST, so a non-super user could
  export via a SQL search_operation.)

Regression cover in integrationTests/security/choose-operation-authz.test.ts:
NESTED-NOSQL now asserts denial, NESTED-OP covers empty/invalid nested operations,
POSITIVE-NOSQL proves a permitted non-SQL export still completes; and the northwind
SQL-export cases now assert the requires_su denial.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cb1kenobi added a commit that referenced this pull request Sep 3, 2026
…ted principal

chooseOperation handed verifyPerms the caller-supplied search_operation, making
both halves of the permission question — principal and tables — body-controlled.
Rebased onto main, which independently landed the SQL operations-allowlist check,
apiOperation token-scope threading (#2176, #2260), and the processAST denial fix
(#2202). This layers the principal/subject hardening on top and closes the gaps
the original PR had deferred:

- Principal comes from authentication: a nested hdb_user is overwritten, never
  backfilled.
- search_operation stands in as the permission subject only for the export
  operations that consume it, must be an object, and must name a supported export
  operation (search_by_value/hash/conditions/sql) — a primitive, {}, or an
  unsupported op is a request-time 400.
- One verifyPerms call cannot authorize both the outer export and its nested
  query: the outer op is authorized first, then the nested search is authorized
  additively against its real search handler and the authenticated principal, so
  a role granted export_local but not the underlying read is denied (previously
  granted at verifyPerms gate 2 before any table check).
- SQL export is routed through verifyPerms too, so its requires_su gate is
  enforced exactly as on the non-SQL path — SQL is no longer a way around it.
  (main's SQL branch checked only the allowlist + AST, so a non-super user could
  export via a SQL search_operation.)

Regression cover in integrationTests/security/choose-operation-authz.test.ts:
NESTED-NOSQL now asserts denial, NESTED-OP covers empty/invalid nested operations,
POSITIVE-NOSQL proves a permitted non-SQL export still completes; and the northwind
SQL-export cases now assert the requires_su denial.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cb1kenobi added a commit that referenced this pull request Sep 4, 2026
…ted principal

chooseOperation handed verifyPerms the caller-supplied search_operation, making
both halves of the permission question — principal and tables — body-controlled.
Rebased onto main, which independently landed the SQL operations-allowlist check,
apiOperation token-scope threading (#2176, #2260), and the processAST denial fix
(#2202). This layers the principal/subject hardening on top and closes the gaps
the original PR had deferred:

- Principal comes from authentication: a nested hdb_user is overwritten, never
  backfilled.
- search_operation stands in as the permission subject only for the export
  operations that consume it, must be an object, and must name a supported export
  operation (search_by_value/hash/conditions/sql) — a primitive, {}, or an
  unsupported op is a request-time 400.
- One verifyPerms call cannot authorize both the outer export and its nested
  query: the outer op is authorized first, then the nested search is authorized
  additively against its real search handler and the authenticated principal, so
  a role granted export_local but not the underlying read is denied (previously
  granted at verifyPerms gate 2 before any table check).
- SQL export is routed through verifyPerms too, so its requires_su gate is
  enforced exactly as on the non-SQL path — SQL is no longer a way around it.
  (main's SQL branch checked only the allowlist + AST, so a non-super user could
  export via a SQL search_operation.)

Regression cover in integrationTests/security/choose-operation-authz.test.ts:
NESTED-NOSQL now asserts denial, NESTED-OP covers empty/invalid nested operations,
POSITIVE-NOSQL proves a permitted non-SQL export still completes; and the northwind
SQL-export cases now assert the requires_su denial.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cb1kenobi added a commit that referenced this pull request Sep 8, 2026
…ted principal

chooseOperation handed verifyPerms the caller-supplied search_operation, making
both halves of the permission question — principal and tables — body-controlled.
Rebased onto main, which independently landed the SQL operations-allowlist check,
apiOperation token-scope threading (#2176, #2260), and the processAST denial fix
(#2202). This layers the principal/subject hardening on top and closes the gaps
the original PR had deferred:

- Principal comes from authentication: a nested hdb_user is overwritten, never
  backfilled.
- search_operation stands in as the permission subject only for the export
  operations that consume it, must be an object, and must name a supported export
  operation (search_by_value/hash/conditions/sql) — a primitive, {}, or an
  unsupported op is a request-time 400.
- One verifyPerms call cannot authorize both the outer export and its nested
  query: the outer op is authorized first, then the nested search is authorized
  additively against its real search handler and the authenticated principal, so
  a role granted export_local but not the underlying read is denied (previously
  granted at verifyPerms gate 2 before any table check).
- SQL export is routed through verifyPerms too, so its requires_su gate is
  enforced exactly as on the non-SQL path — SQL is no longer a way around it.
  (main's SQL branch checked only the allowlist + AST, so a non-super user could
  export via a SQL search_operation.)

Regression cover in integrationTests/security/choose-operation-authz.test.ts:
NESTED-NOSQL now asserts denial, NESTED-OP covers empty/invalid nested operations,
POSITIVE-NOSQL proves a permitted non-SQL export still completes; and the northwind
SQL-export cases now assert the requires_su denial.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants