Skip to content

fix(security): authorize the invoked operation against the authenticated principal - #2217

Open
cb1kenobi wants to merge 6 commits into
mainfrom
fix/choose-operation-authz
Open

fix(security): authorize the invoked operation against the authenticated principal#2217
cb1kenobi wants to merge 6 commits into
mainfrom
fix/choose-operation-authz

Conversation

@cb1kenobi

@cb1kenobi cb1kenobi commented Aug 19, 2026

Copy link
Copy Markdown
Member

Fixes operation authorization in chooseOperation: authorize the invoked operation against the authenticated principal, and stop a caller-supplied search_operation from standing in as the permission subject.

What this fixes

verifyPerms reads both halves of the permission question off the object it's handed — the principal from hdb_user and the tables from schema/database/table/records. chooseOperation used to hand it json.search_operation, a caller-supplied field, making both halves body-controlled. Four issues, each with regression cover in integrationTests/security/choose-operation-authz.test.ts:

  1. Cross-privilege redirectverifyPerms ran against json.search_operation ?? json for every operation, but the handler runs against top-level json and only dataLayer/export.ts consumes search_operation. A non-super user could send a privileged top-level operation with a benign search_operation and get authorized against the benign tables. Now search_operation is the permission subject only for export_local/export_to_s3; every other op is checked against top-level json.
  2. Principal smuggling — the nested principal was backfilled (if (!hdb_user)), honoring a body-supplied one. Now hdb_user is overwritten from the authenticated top-level principal unconditionally.
  3. parsed_sql_object smuggling — the export worker re-reads parsed_sql_object (carrying permissions_checked) off the nested object, so a body-supplied one would execute an AST no check ever saw. It's now deleted at dispatch, forcing a re-parse + re-check in the worker.
  4. Silent SQL denialprocessAST's guard tested permissionsCheck.length > 0, but a denial is a PermissionResponseObject with no .length, so undefined > 0 was always false and denials executed. Fixed to if (permissionsCheck).

Non-object search_operation on an export op is now a 400 (was a wrapped 500), and the SQL AST check runs additively after verifyPerms rather than as an exclusive branch.

For the reviewer

  • server/serverHelpers/serverUtilities.ts (chooseOperation) is the core. The load-bearing invariant: the object passed to verifyPerms must be the one the handler actually operates on. DESIGN.md (new chooseOperation section) documents the three rules and why each is required.
  • export_local table check is currently unreachable — both export ops are requires_su and every path returns before the nested table check (super_user early-returns; a role granted the op via operations returns at gate 2; anything else is refused earlier). So the nested-table substitution is inert today. This is intentional for this patch.

Behavior change (release note)

Non-super users can no longer invoke export_local / export_to_s3 via a SQL search_operation. These operations are requires_su, and NoSQL export already enforced that ("Operation 'export_local' is restricted to 'super_user' roles"). SQL-based export previously took the SQL-only branch and skipped verifyPerms, so a non-super user could invoke the privileged export by wrapping it in a SQL search — governed only by table read perms. Routing export through verifyPerms closes that bypass and brings SQL export to parity with NoSQL export. Any non-super role that relied on SQL-export-without-export_local will now receive a 403. Three northwind export tests that encoded the old bypass were updated to assert the requires_su denial (they now mirror the existing NoSQL export case; they could be consolidated).

Deferred gaps (tracked, not fixed here)

Two pre-existing authorization gaps are left open deliberately — closing either changes authorization outcomes for existing role configurations, so each needs its own change + release note. Both are documented in DESIGN.md and pinned by regression tests so there's a test to flip:

Verification

  • integrationTests/security/choose-operation-authz.test.ts — pins the denial shapes and the current boundaries (principal override, nested-shape 400, forged-AST rejection, and the two deferred-gap boundaries NESTED-NOSQL / gate-1).
  • Deep-review, multipass (2 independent runs × auth + SQL/AST lenses). Confirmed the fix closes the cross-privilege redirect, principal smuggling, AST smuggling, and the processAST denial-drop, with no other .length-style denial-drop anywhere in the tree. The multipass independently rediscovered the two deferred gaps above (now ticketed). One low cleanup nit surfaced: serverUtilities.ts:298 sets a top-level parsed_sql_object that's inert for the export path.

Review coverage

  • Deep-review, multipass (Claude — Harper-domain + SQL/AST lenses, 2 independent runs): confirmed the fix closes the cross-privilege redirect, principal smuggling, AST smuggling, and the processAST denial-drop; no other .length-style denial-drop in the tree.
  • Cross-model, Codex (graded, opposite-family): verdict COMMENTS, no production blockers. Two findings, both in the test file (below).
  • Degraded legs (noted for honesty): Gemini agy failed (stochastic print-mode hang; CLI retries + 2 manual attempts all timed out) — no perf/maintainability lens this run; cursor-composer not authenticated locally; the CLI's Claude domain-adjudicator leg couldn't run (claude not on PATH) — covered instead by the separate multipass deep-review above.

Unresolved review findings (test-only, non-blocking)

  • Codex, minorintegrationTests/security/choose-operation-authz.test.ts: no end-to-end case proves the export worker's nested-SQL recheck can deny an unauthorized query. The allowed super-user path reaching COMPLETE is covered, and unitTests/sqlTranslator/processAST.test.js proves the object-shaped denial stops routing, but the full dispatch → principal-propagation → reparse → denial path isn't exercised together. Worth adding a non-super-user unauthorized-export-SQL case that asserts denial.
  • Codex, nit — same file: trim added comments that narrate tests / restate identifiers / address the reviewer (the suite catalog + run-command block at the top, and identifier restatements).

…ted principal

`chooseOperation` hands `verifyPerms` the nested `search_operation` when present, so an
export job's query gets checked. Because `search_operation` is caller-supplied, two of
its properties were reaching decisions they should not influence.

The nested `hdb_user` was backfilled only `if (!operation_json.hdb_user)`, so a
body-supplied principal was honored rather than replaced. Authentication establishes the
top-level principal only, so assign it unconditionally. All four callers of
`chooseOperation` set that principal before dispatch, which is why the fix belongs here
and not only at the HTTP boundary — the component, worker-forwarding, and MCP entry
points would otherwise be uncovered.

The SQL branch keyed on the nested `operation` name and was `else if`-exclusive with the
`verifyPerms` branch, so the nested value could decide which check ran. `verifyPermsAST`
is not a substitute for `verifyPerms`: it validates the statement's tables and attributes
only, never the role `operations` allowlist or `requires_su`. Run it additively instead —
`verifyPerms` for the job operation, the AST check for the nested statement.

Also fix the `processAST` guard, which tested `.length` on a `PermissionResponseObject`
and so could never refuse.

Gate 1 remains unenforced on the SQL path; that changes authorization outcomes for
existing role configs and needs its own change.
Two gaps found by the planning review.

The positive export case asserted only that `export_local` returned its asynchronous
start response, so it could not distinguish a job that ran from one the job worker
refused after the API answered — the case that matters, since the worker re-parses the
nested SQL and runs the AST check that previously could not deny. Poll `get_job` to a
terminal state and assert COMPLETE, writing to a real temp directory.

The existing `processAST` tests stubbed an array-shaped denial, which satisfies a guard
testing `.length` as well as one testing the object, so they passed either way. Stub a
real `PermissionResponseObject`.
… that consume it

The first commit fixed which principal `verifyPerms` evaluates and left which tables it
evaluates still caller-controlled. `verifyPerms` reads both off the object it is handed,
so substituting `json.search_operation` for every operation meant an empty nested object
produced an empty table map — and `hasPermissions` iterating nothing authorizes
everything, on reads and writes alike.

`dataLayer/export.ts` is the only consumer of `search_operation`, and for `export_local`
and `export_to_s3` the substitution is correct: `functionToCheck` remains the export
operation, so the operation check and the nested table check both run. Gate the
substitution on those two operations; every other operation is now checked against its
own body.

Also drop a body-supplied `parsed_sql_object` from the nested object. The export worker
re-reads that field off the caller's own object and it carries `permissions_checked`, so
a forged one executed an AST that no check had seen. Dropping it means the honest `sql`
is what runs.

Extends the regression suite with both vectors, using principals that actually reach the
table checks — a role declaring `operations` is refused at gate 1 first, which would have
made these cases pass without exercising anything. Also corrects assertions that could
pass vacuously: exact 403 rather than 401-or-403, a `list_users` shape check, and the real
JOB_STATUS_ENUM values.

DESIGN.md previously claimed nothing in the permission subject comes from the body, which
was not true of the table half. Rewritten to state the three rules that actually hold, and
to record two gaps left open deliberately: gate 1 on the SQL path, and a role granted
`export_local` passing gate 2 before any table check.
…the export table check is unreachable

From the cross-model review.

A primitive `search_operation` on an export reached the principal assignment and threw a
TypeError, which the surrounding catch turned into a 500. It is the permission subject, so
its shape is a 400.

Chasing a test that asserted the nested table check runs showed that it never does: both
export operations are `requires_su`, and every path returns before it — a super_user
returns early, a role granted the operation through `operations` returns at gate 2, and
any other role is refused earlier. The substitution is therefore inert today, and the
earlier DESIGN.md wording calling it coherent for those operations was misleading.
Rewritten to say so, and the case is kept as a characterization test that pins the
current behavior so narrowing the gate-2 grant has a test to flip.

Adds the nested-NoSQL and shape cases the review found missing, and trims comments that
narrated the code rather than recording a constraint.

Not taken: attaching the authorized parse to the nested object instead of deleting it. That
would set `permissions_checked` on an object the caller supplies and make the export worker
skip its own re-check — the re-parse is deliberate, and it is what neutralizes a forged AST.
@cb1kenobi cb1kenobi added the area:security Security, TLS/certs, authentication, authorization label Aug 19, 2026
@cb1kenobi
cb1kenobi requested review from heskew and kriszyp August 19, 2026 15:00

@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 strengthens the security and authorization model of the operations API, specifically within chooseOperation. It ensures that the authenticated principal is always enforced and prevents body-supplied parameters (like nested hdb_user or parsed_sql_object) from bypassing authorization checks. Additionally, it fixes a critical bug in processAST where permission denials (which are objects lacking a length property) were not correctly blocked. The review feedback suggests a minor optimization in chooseOperation to avoid polluting the top-level request object with parsed_sql_object when executing nested SQL searches, as it is only consumed by direct SQL operations.

Comment on lines +294 to +298
if (isSqlOperation || hasNestedSqlSearch) {
const sql = require('../../sqlTranslator/index');
const sqlStatement = isSqlOperation ? json.sql : nestedSearch.sql;
const parsedSqlObject = sql.convertSQLToAST(sqlStatement);
json.parsed_sql_object = parsedSqlObject;

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.

medium

The top-level json.parsed_sql_object is only consumed by direct SQL operations (where isSqlOperation is true) to avoid re-parsing in evaluateSQL. For nested SQL searches (like in export jobs), the worker executes evaluateSQL on the nested search_operation object (where parsed_sql_object is deliberately deleted to force a re-parse and re-check). Therefore, setting json.parsed_sql_object on the top-level json when isSqlOperation is false is inert and unnecessarily pollutes the request body. We should only assign it when isSqlOperation is true.

		if (isSqlOperation || hasNestedSqlSearch) {
			const sql = require('../../sqlTranslator/index');
			const sqlStatement = isSqlOperation ? json.sql : nestedSearch.sql;
			const parsedSqlObject = sql.convertSQLToAST(sqlStatement);
			if (isSqlOperation) {
				json.parsed_sql_object = parsedSqlObject;
			}

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@cb1kenobi
cb1kenobi marked this pull request as ready for review August 19, 2026 16:17
The three "Export To Local using SQL as test_user" cases asserted that a
non-super user could export via a SQL search (200), or hit only the AST
table check. That was a requires_su bypass: NoSQL export already denied
non-super users with "Operation 'export_local' is restricted to
'super_user' roles". The chooseOperation fix routes SQL export through
verifyPerms too, so all three now assert the same requires_su denial as
the existing NoSQL export case.

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

area:security Security, TLS/certs, authentication, authorization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant