Skip to content

Allow a component-registered operation to be granted in a role's operations allowlist - #2260

Merged
kriszyp merged 16 commits into
mainfrom
fix/grantable-component-ops-cross-thread
Aug 26, 2026
Merged

Allow a component-registered operation to be granted in a role's operations allowlist#2260
kriszyp merged 16 commits into
mainfrom
fix/grantable-component-ops-cross-thread

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

An operation a component registers with requiresSuperUser is meant to be grantable in a role's operations allowlist, but naming it in add_role, alter_role, or an impersonation payload was rejected as "not a valid operation name or group" — the grantable mark was made in the worker that registered the operation, while the validation that reads it runs on the main thread. The registration announcement now carries that fact across the thread boundary, so a scoped role can actually be granted a component operation.

Enforcement is unchanged: the main thread only decides whether a name is admissible in an allowlist, and the operation still runs through the registering worker's own chooseOperation.

One finding is disclosed rather than fixed, and is inherited from the registerOperation bridge (#1736) rather than introduced here — the announcement is fire-and-forget, so an add_role naming a component operation can still lose a race against component load at startup. It fails closed (a rejected role), and the existing execution path has the same window.

For the human reviewer

The step-6 framing gate did not clear. The planning review returned Framing-Verdict: better-alternative-exists, and it was right — I adopted its recommendation rather than defending mine, which is why the history carries superseded approaches (squash on merge). The entries below lead with the parts I got wrong, because those are the ones worth your scepticism:

  1. The approach changed after review, and the earlier one was genuinely broken. I first mirrored grantability as a set of names. That cannot enforce the invariant the design claimed ("admissible iff a live worker declares it grantable"): on a rolling deploy whose new generation keeps an operation but drops requiresSuperUser, the routing set stays non-empty via the new workers, so the mark is never revoked — add_role accepts a grant whose execution then fails closed with operation-not-found. Grantability is now tracked per declaring thread (name -> Set<threadId>) and the mirrored mark is re-derived from live claims. What to check: that setWorkerGrantable re-derives on every path, including retraction, and that handleThreadExit retracts one thread's claim while preserving routing for survivors.
  2. My "main-mediated registration" rejection was overstated, and I did not adopt the correction. I disqualified it as requiring server.registerOperation() to become async. The reviewer pointed out that the loader could instead collect synchronous registrations and await one batched acknowledgement before declaring the worker ready — no API change, and it would also close the fire-and-forget startup window disclosed above. I did not do it: it introduces a worker-readiness protocol in the component loader, which is a materially larger change than the one this bug needs. This is the entry most worth overruling. If you want the startup window closed rather than documented, that is the design to ask for, and this PR is the wrong shape for it.
  3. A mixed worker generation can make a granted call succeed intermittently, and I declined to fix it. If the old generation declares an operation grantable and the replacement omits requiresSuperUser, a role grant is accepted while both generations are live, and calls then alternate between succeeding on the declaring worker and returning a permission error on the other. Routing ignores grantableByWorker deliberately: selecting a worker by authorization metadata would require the main thread to know why a caller is authorized (super_user vs. an operations grant), which is the authorization determination this module documents that main must not make — server.registerOperation() calls in a component's resources.js are unreachable via the ops API #1736 split it as main routes, worker enforces. It fails closed (a permission error, never a wrongful success) and it is transient: once the old generation is gone this change retracts grantability and the grant is correctly rejected at write time. If you would rather routing became authorization-aware, that is a design call, and this is where to make it.
  4. grantable is derived as requiresSuperUser !== undefined rather than being a new field on OperationDefinition. It reuses the existing tri-state that already decides whether registerOperationPermission is called at all, so the two cannot drift; the cost is that the wire flag's meaning is implicit in that expression. Note this deliberately treats requiresSuperUser: false as grantable, which matches the tri-state's documented meaning ("grantable AND open to any authenticated user") and keeps worker-registered operations behaving like main-thread ones.
  5. Arming the thread-exit cleanup at module load widens this PR past the stated bug. attachMainListeners() previously ran only on the first forwarded call. Thread-exit notification fires once per thread and is dropped when no listener is attached, so a worker that registered and died before that first call leaked its entries permanently. That is a pre-existing bug in the routing map — I fixed it here because the new mark would otherwise inherit it, but it is separable if you would rather see it on its own.

Also flagged by the planning review and not addressed: it recommended lifecycle tests driven through a real hot restart/deploy, and integration coverage of a mixed old/new generation. The new unit tests drive the exit seam directly instead, so they prove the state machine but not that a real restart_service produces those transitions.

Adjacent bug found while checking symmetry, filed rather than fixed here: #2203 (P2, under [Epic] Component authoring & packaging DX) — a component's own roles.yaml still cannot grant an operation its own resources.js registers, because DEFAULT_CONFIG orders the roles plugin before jsResource and componentLoader.ts:542 iterates config keys in order. Same-thread ordering, different mechanism, survives this fix.

This invalidates documentation of the limitation in two places that are still in flight, neither on a main branch, so there is no companion docs PR to open yet: the Known limitation comment on assertOperationsAreKnown in security/authn/oidc/trustPolicyOperations.ts on #2173's branch, and the "that registry is process-local, so a policy naming one is rejected" paragraph in reference/operations-api/operations.md on an unpushed HarperFast/documentation branch. Whichever of the two PRs lands second should drop both. Nothing currently on documentation main is made false by this change.

Windows CI is red for reasons outside this PR (#2273, #2313), and the repo owner has accepted that for this merge. Integration Tests 2/6 fails Component: risk-query and describe_all metadata upgrade, both npm work → restart_service → route readiness, which is #2273 (deploy_component (restart: true) hangs after npm pack) — open and reproducing on main. Integration Tests 6/6 fails set_configuration, which is #2313 (Windows config-write rename retry blocking the thread), with fixes in flight in #2339 and #2191. Neither is reachable from this diff.

For the record, since an earlier version of this description said otherwise: I bisected the 2/6 failure to a commit here and reverted it on that basis. That was wrong. The same test fails at 350s with the commit reverted and ranges 259–465s across builds with identical code, so ~200s of variance was being read as a 110s signal. The revert has been undone in 9ac125c45 and the variance data is on #2273 so the next person doesn't repeat it.

Verification

Route (a), extended existing integration testintegrationTests/components/registered-operation.test.ts gains six cases in a nested suite covering add_role, alter_role, impersonation, an end-to-end grant (a non-super_user with the grant executes the operation on a worker), and two negative controls: an unregistered name is still rejected, and a non-super_user without the grant still gets 403. No new CI entry.

HARPER_INTEGRATION_TEST_LOOPBACK_POOL_START=1 HARPER_INTEGRATION_TEST_LOOPBACK_POOL_COUNT=1 \
  npm run test:integration -- --isolation=none integrationTests/components/registered-operation.test.ts
→ 11/11 pass (5 pre-existing + 6 new)

Unit — seven new cases in unitTests/server/serverHelpers/serverUtilities.test.js, covering the rolling-deploy retraction, withdrawal by re-announcement, the exited-thread announcement guard, main/worker mark independence, and the failed-send retraction through its real trigger (sendToThread reporting a dead port). npx mocha "unitTests/server/serverHelpers/*.test.js" → 295 passing, 1 failing. That failure is pre-existing (uwsServer.test.js, "rejects a body over maxBodyBytes with 413"); confirmed by stashing this branch's changes and re-running. Run as the whole suite rather than the changed file alone, because these tests mutate the process-global grantable registries. Also npx mocha unitTests/utility/operationPermissions.test.js unitTests/security/impersonation.test.js unitTests/validation/role_validation.test.js → 133 passing, 0 failing.

fails-on-base — through a detached worktree at the merge-base with origin/main, with only the test changes applied: the new unit cases fail there and reach their intended assertions rather than a setup or compile error; the 4 positive integration cases fail there while both negative controls pass. Everything passes on the branch.

Rebased onto current origin/main (374408e) before review — the branch had fallen 67 commits behind. No conflicts, and origin/main had touched none of these 7 files.

Not run locally: test:unit:main and test:unit:resources abort at load on this machine — another process holds ~/harper/database/data/LOCK, and HDB_ROOT/ROOTPATH overrides do not reroute the path that opens it. Worth knowing separately: mocha exits 0 when it dies this way, so an exit code is not evidence those suites ran. test:integration:all (160 files) was not run locally either: without the loopback address pool configured it can only run sequentially against 127.0.0.1. Both are left to this PR's Actions run.

The coverage field below pins the final round, which was a comment-only delta. The substantive round on this code (b1b267ecd) was classified high-risk / security-auth and ran three outside lenses — codex, gemini and cursor-grok — at high effort; it is the round that found the re-registration authorization divergence fixed in 5daaa6d96. Harper domain adjudication failed or was pruned on every round, so no round was filtered by the leg that drops false positives, and I triaged findings myself.

npm run lint:required clean; npm run format:write produced no changes; npm run build clean; TypeStrip/tabs/node:-prefix/assert-style greps clean over origin/main...HEAD.

Complexity: complicated

Review-Coverage: authored=claude; ran=codex; blocked=gemini(quota); declined=cursor-grok,cursor-composer,domain; rounds=11 @ 9ac125c

Human-Review-Need: 4 @ 9ac125c

@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 implements cross-thread mirroring of grantable operations from worker threads to the main thread, allowing the main thread to validate worker-registered operations in role allowlists. The review feedback suggests two performance optimizations in server/serverHelpers/registeredOperations.ts: first, to only perform cleanup logic during thread exit if the exiting thread actually registered the operation, and second, to avoid redundant Map/Set deletions in setWorkerGrantable when removing a thread's grantable claim.

Comment thread server/serverHelpers/registeredOperations.ts
Comment thread server/serverHelpers/registeredOperations.ts
@claude

This comment has been minimized.

dawsontoth and others added 11 commits August 21, 2026 16:53
`server.registerOperation({ requiresSuperUser })` marks an operation
grantable in a role's `operations` allowlist, but that mark landed only in
the worker that registered it (components load per-worker). Meanwhile
`validateOperations` is consulted on the main thread — by add_role and
alter_role, by impersonation payload validation, and by OIDC trust
policies — so naming a component-registered operation in any of those was
rejected as "not a valid operation name or group" even though the
operation existed and was designed to be grantable.

The OPERATION_REGISTERED announcement already crosses that boundary for
execution routing, so carry grantability on it too and mirror the mark on
main. This only widens what an allowlist may name; enforcement is
unchanged, still running on the worker's own `chooseOperation`.

Also arm the thread-exit cleanup when the registry gains its first entry
rather than on the first forwarded call, so a worker that registers and
exits without ever being called no longer leaks its entries, and revoke
the mirrored mark when the last registering worker is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claim a mirrored name for thread-exit cleanup only when the mirror is what
made it admissible. As written, the ownership set was unconditional, so a
name that main had already registered itself — or an enum/group name — was
revoked when the last worker offering the same name exited, which is the
opposite of what the set exists to prevent and of what its comment claimed.
Flagged independently by both review lenses.

Route both prune paths through one `dropRegistration`: the failed-send path
in `executeRemoteOperation` dropped the routing entry without revoking the
mark, so routing and grantability could disagree about whether an op was
still offered.

Also cut the comments the review flagged as narration or as duplicating the
note now carried in server/DESIGN.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ownership probe added in the previous commit only protected marks that
predated a worker's announcement, which leaves a reachable hole: on a hot
deploy `restartWorkers` awaits `loadRootComponents()` before it begins
draining the old workers (`server/threads/manageThreads.js`), so a
`startOnMainThread` component can register an operation the retiring worker
also offered. The worker's exit then revoked the main thread's own mark and
role validation started rejecting an operation that was registered and
executable.

Track mirrored names in a separate set that `validateOperations` unions
instead of sharing one. The two threads can now register the same name
independently and neither can revoke the other, which removes the ownership
question rather than narrowing it — the probe and its bookkeeping set are
gone. Found by the round-2 cross-model review, which also supplied this
approach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The planning review returned better-alternative-exists on the previous
approach, and it was right: a name-level mirror set cannot express which
worker declared the operation grantable, so it did not enforce the invariant
the design claimed ("admissible iff a live worker declares it grantable").

Concretely, a rolling deploy whose new generation keeps an operation but drops
`requiresSuperUser` left the name admissible with no live declarer: the
routing set stayed non-empty via the new workers, so the mark was never
revoked, and `add_role` accepted a grant whose execution then failed closed
with operation-not-found.

Track claims as name -> Set<declaring threadId> and re-derive the mirrored
mark from live claims, so grantability is retracted when a thread withdraws it
or exits even while other workers keep routing the name. Also ignore an
announcement from a thread already reported dead: exit notification is
deduplicated for the process lifetime, so such an entry could never be
cleaned up afterwards.

Extract the thread-exit cleanup and export a test seam for it, which is what
finally lets the revocation paths be tested at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two real gaps found by the gemini and cursor-composer legs, corroborating
each other on the second one.

Arm the main-thread listeners at module load instead of on first use.
`attachMainListeners` ran lazily from the registration handler, but
thread-exit notification fires once per thread and is dropped outright when no
listener is attached yet — so a worker that died before its first
announcement was processed left a registration nothing could ever clean up,
and the exited-thread guard never learned about it. serverUtilities imports
this module during its own load, before any worker exists, so arming at load
is well ordered.

Retract grantability when a failed send prunes a dead originator.
`executeRemoteOperation` dropped the routing entry but left the claim, so a
dead worker could keep a name admissible while a surviving worker that never
declared a permission kept routing it — the same false-admissible case this
change exists to close.

Also guard the ITC payload destructure. A malformed OPERATION_REGISTERED with
no `message` would have thrown on the main thread; the envelope is trusted and
in-process, but three review rounds have now flagged it and the guard is one
expression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Synthetic thread ids in the new tests were small positive integers inserted
permanently into the module-global tombstone set, which the `after` hook
cannot clear — a later suite starting a real worker in the same process could
have been assigned one of those ids and had its legitimate announcement
ignored. Use ids the runtime will never assign.

Cover the failed-send retraction through its production trigger rather than
only the exit seam: a forward whose `sendToThread` reports a dead port must
retract the claim, not just the route.

Also drop comments the review flagged as narrating the line beneath them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both suggestions from the gemini review, and both behaviour-preserving: a
grantability claim implies a registration, since claims are only recorded
alongside one, so an operation the dead thread was not registered for can have
no claim to retract either.

`handleThreadExit` now continues when the id was not in the routing set, and
`setWorkerGrantable` only re-derives the mirrored mark when a claim was
actually removed, instead of unregistering a name it never held.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`add_oidc_trust` is the third main-thread caller of `validateOperations`, and
until this change its own source carried a `Known limitation` note saying a
component-registered operation "is NOT recognized here and a policy naming one
is rejected", pointing at this bridge as where the fix belonged. That note is
now false, so remove it rather than leave a comment describing behaviour the
code no longer has.

The operation was absent from the checkout when this branch started, which is
why the earlier rounds could only cover add_role/alter_role and impersonation.
Add the two cases that were always wanted: a trust policy naming a
component-registered operation is accepted, and one naming an unregistered
operation is still rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment on "accepts an operation registered in this process" said a
component's operation "is NOT recognized here in production" and named this
bridge as where the fix belonged. The test itself is unchanged and still
correct — it asserts the delegation to validateOperations — but its
explanation described behaviour this branch removes.

Surfaced by the review leg grepping for leftovers after the source comment
came out, which is the half-true remnant that sweep was looking for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cross-model review found a real authorization divergence this branch
introduced. Registering an operation with `requiresSuperUser` installs a
`requiredPermissions` entry keyed by the operation name; re-registering the
same name without the flag left that entry in place. Before this branch that
was inert, because the operation could never have been granted in the first
place. Now main retracts the mirrored grantable mark on the re-announcement
while the worker keeps honouring a role grant persisted earlier, so the
declaration and the enforcement disagree — reachable whenever the handler's
own `.name` matches the operation name, which is a natural way to write one.

Retract the entry alongside the mark, tracking the names this API installed so
a component re-declaring a built-in's name cannot strip the built-in's
permission. Tests cover both directions: a persisted grant stops being honoured
once the declaration is dropped, and an entry registered by anyone else
survives.

Also finishes the limitation cleanup the review caught mid-flight and trims the
comments it flagged as narration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment overstated the guard: a bare flagless registration cannot clear a
built-in's entry, but declaring that name first puts it in
declaredPermissionNames, so a later flagless registration can. The declaring
call already overwrote the built-in entry at that point, so this only follows
it — but the comment claimed a guarantee the code does not make.

Also drops the two comments the review flagged as restating the line beneath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the fix/grantable-component-ops-cross-thread branch from 2169d46 to b7d33a0 Compare August 24, 2026 18:15
@dawsontoth
dawsontoth marked this pull request as ready for review August 24, 2026 18:38

@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.

Nice catch, good work!
🤖 Reviewed with Codex

Comment thread server/serverHelpers/registeredOperations.ts Outdated
dawsontoth and others added 2 commits August 24, 2026 15:56
Review pointed out that jobs launch a fresh `autoRestart: false` worker per
job (server/jobs/jobRunner.ts), so a per-thread tombstone here grows with every
completed job on a long-lived node — not once per worker restart, which is what
the comment claimed. `manageThreads` already records exactly this in
`notifiedDeadThreadIds`, and records it before firing exit listeners, so the
local set was duplicating state that was already there and already correct.

Expose a sync `hasThreadExited` from `manageThreads` and read that instead.
`isThreadRunning` cannot serve: it is async because it awaits process-group
confirmation, and this runs on a synchronous announcement path.

Export `notifyThreadExit` too, which lets the lifecycle tests drive the real
exit path and removes `notifyThreadExitedForTest` from production entirely. The
tests now exercise the `onThreadExit` wiring they previously bypassed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It placed the dedupe "above" the export when notifyThreadExit is defined far
below it, and an export list is not where that rationale belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth requested a review from kriszyp August 25, 2026 14:30
@dawsontoth

Copy link
Copy Markdown
Contributor Author

ah, this needs 1 re-approval

@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!
🤖 Reviewed with Codex

Comment thread unitTests/server/serverHelpers/serverUtilities.test.js Outdated
The test comment still said exitedThreadIds, which no longer exists. Phrased
without naming the holding set so it stays true regardless of which module
owns it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread server/threads/manageThreads.js
@dawsontoth
dawsontoth marked this pull request as draft August 25, 2026 23:24
Reverts the shared-registry reuse from d97a5cc. CI bisects a Windows
regression to that commit: `Integration Tests 2/6 (Windows)` fails with it on
two independent builds (375s, 389s, then 465s on a fresh build) and passes
without it (259s), matching main at 262-280s. `manageThreads.js` is untouched
again as a result.

I do not have a root cause. Reading `notifiedDeadThreadIds` instead of an
equivalent local Set should not cost minutes of HTTP-worker readiness, and it
does not reproduce on macOS, so the revert is on evidence rather than
understanding.

Kris's underlying point stands and is answered in the comment instead: the set
holds one integer per dead thread, which is the same growth profile
manageThreads already accepts for notifiedDeadThreadIds a few lines from where
it records them. Narrowing it to threads that already hold a registration was
tried and rejected — it defeats the guard's purpose, because the case it exists
for is a thread whose FIRST announcement is in flight when it dies, and such a
thread holds no registration at exit time. A unit test covers that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverts 9d66638, which was made on a conclusion I have since retracted.

I had bisected a Windows failure to the shared-registry change and reverted it
on that basis. The bisect was noise: the same test fails at 350s with the change
reverted, and ranges 259-465s across builds with identical code, so ~200s of
variance was being read as a 110s signal. Windows is red here for #2273
(risk-query and describe_all: npm work, then restart_service, then route
readiness — open and reproducing on main) and #2313 (set_configuration), neither
reachable from this diff.

So this restores the better shape, which is also what review asked for: no second
dead-thread registry beside the one manageThreads already maintains, and
notifyThreadExitedForTest is out of production surface again, with the lifecycle
tests driving the real onThreadExit event instead of a module-local stand-in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth marked this pull request as ready for review August 26, 2026 14:04
@dawsontoth
dawsontoth requested a review from kriszyp August 26, 2026 14:19

@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.

🤖 Reviewed with Codex

@kriszyp
kriszyp merged commit 18572f4 into main Aug 26, 2026
50 of 52 checks passed
@kriszyp
kriszyp deleted the fix/grantable-component-ops-cross-thread branch August 26, 2026 14:24
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>
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.

2 participants