Skip to content

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

Draft
dawsontoth wants to merge 1 commit into
mainfrom
fix/process-ast-permission-guard
Draft

fix(security): honor the SQL permission denial processAST computes#2202
dawsontoth wants to merge 1 commit into
mainfrom
fix/process-ast-permission-guard

Conversation

@dawsontoth

Copy link
Copy Markdown
Contributor

Split out of #2173 at a reviewer's request — the defect is pre-existing and affects all SQL authorization, not just that feature.

The bug

sqlTranslator/index.ts guarded its permission check with:

let permissionsCheck = checkASTPermissions(jsonMessage, parsedSqlObject);
if (permissionsCheck && permissionsCheck.length > 0) {

checkASTPermissions returns either null or a PermissionResponseObject. That class has error, unauthorized_access, and invalid_schema_itemsno length. So the condition evaluated undefined > 0, was always false, and the denial was computed correctly and then dropped on the floor.

Why nobody noticed

This branch is normally the second permission check, not the first. A direct sql call reaches processAST with permissions_checked already true, set by chooseOperation — whose own consumer of the same function uses a correct bare truthiness test (server/serverHelpers/serverUtilities.ts). So in the common path the branch never runs.

It runs when something re-parses a statement: a job dispatches its nested search_operation, which carries no parsed_sql_object, so evaluateSQL re-parses with permissions_checked === false. That is precisely the path with no outer gate behind it.

The fix

A bare truthiness test, matching the identical consumer in serverUtilities.ts.

Tests

unitTests/sqlTranslator/processASTPermissions.test.js drives processAST directly rather than asserting that a denial is merely computed — which is how a dead consumer went unnoticed in the first place. Four cases:

  • a denied statement comes back 403 with the permission response
  • the response object survives intact for the caller to render
  • an allowed statement is still allowed (this must not start denying what was always permitted)
  • an already-checked statement still skips the branch

The two negative cases were confirmed to fail against the old guard before being kept (2 passing / 2 failing when reverted, 4 passing with the fix).

Scope

No behavior change for any statement that was correctly authorized. The change is that a denial computed on the re-parse path is now acted upon instead of ignored.

🤖 Generated with Claude Code

`processAST` guarded its permission check with
`permissionsCheck && permissionsCheck.length > 0`, but `checkASTPermissions`
returns either null or a `PermissionResponseObject` — a class with no `length`.
So the test evaluated `undefined > 0`, was always false, and every denial
reaching that branch was computed correctly and then discarded.

This survived because the branch is normally the second check rather than the
first: a direct `sql` call arrives with `permissions_checked` already true from
`chooseOperation`, whose own guard (`if (astPermCheck)`) is correct. The branch
only executes when something re-parses a statement — a job dispatching its
nested `search_operation` — which is exactly the path with no outer gate behind
it.

Now a bare truthiness test, matching the identical consumer in
serverUtilities.ts. Tests drive processAST directly, including the
already-checked and allowed paths so this cannot start denying statements that
were always permitted; the two negative cases were confirmed to fail against the
old guard.

Found while reviewing #2173, which does not depend on this: its own gate refuses
the export/write combination at the front door. Split out because the defect is
pre-existing and affects all SQL authorization, not just that feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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.

1 participant