fix(security): authorize the invoked operation against the authenticated principal - #2217
fix(security): authorize the invoked operation against the authenticated principal#2217cb1kenobi wants to merge 6 commits into
Conversation
…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.
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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;
}|
Reviewed; no blockers found. |
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>
Fixes operation authorization in
chooseOperation: authorize the invoked operation against the authenticated principal, and stop a caller-suppliedsearch_operationfrom standing in as the permission subject.What this fixes
verifyPermsreads both halves of the permission question off the object it's handed — the principal fromhdb_userand the tables fromschema/database/table/records.chooseOperationused to hand itjson.search_operation, a caller-supplied field, making both halves body-controlled. Four issues, each with regression cover inintegrationTests/security/choose-operation-authz.test.ts:verifyPermsran againstjson.search_operation ?? jsonfor every operation, but the handler runs against top-leveljsonand onlydataLayer/export.tsconsumessearch_operation. A non-super user could send a privileged top-level operation with a benignsearch_operationand get authorized against the benign tables. Nowsearch_operationis the permission subject only forexport_local/export_to_s3; every other op is checked against top-leveljson.if (!hdb_user)), honoring a body-supplied one. Nowhdb_useris overwritten from the authenticated top-level principal unconditionally.parsed_sql_objectsmuggling — the export worker re-readsparsed_sql_object(carryingpermissions_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.processAST's guard testedpermissionsCheck.length > 0, but a denial is aPermissionResponseObjectwith no.length, soundefined > 0was always false and denials executed. Fixed toif (permissionsCheck).Non-object
search_operationon an export op is now a 400 (was a wrapped 500), and the SQL AST check runs additively afterverifyPermsrather than as an exclusive branch.For the reviewer
server/serverHelpers/serverUtilities.ts(chooseOperation) is the core. The load-bearing invariant: the object passed toverifyPermsmust be the one the handler actually operates on.DESIGN.md(newchooseOperationsection) documents the three rules and why each is required.export_localtable check is currently unreachable — both export ops arerequires_suand every path returns before the nested table check (super_user early-returns; a role granted the op viaoperationsreturns 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_s3via a SQLsearch_operation. These operations arerequires_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 skippedverifyPerms, 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 throughverifyPermscloses that bypass and brings SQL export to parity with NoSQL export. Any non-super role that relied on SQL-export-without-export_localwill now receive a 403. Threenorthwindexport tests that encoded the old bypass were updated to assert therequires_sudenial (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.mdand pinned by regression tests so there's a test to flip:operationsallowlist bypasses table-level grants (non-SQL nested search) #2215 — a role grantedexport_local/export_to_s3viaoperationscan export tables it holds no grant on (non-SQL nested search reaches the handler with no table check).sqloperation bypasses the operations allowlist (gate 1) #2216 — a directsqloperation bypasses theoperationsallowlist (gate 1); authorized only by the AST table check.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 boundariesNESTED-NOSQL/ gate-1).processASTdenial-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:298sets a top-levelparsed_sql_objectthat's inert for the export path.Review coverage
processASTdenial-drop; no other.length-style denial-drop in the tree.agyfailed (stochastic print-mode hang; CLI retries + 2 manual attempts all timed out) — no perf/maintainability lens this run;cursor-composernot authenticated locally; the CLI's Claude domain-adjudicator leg couldn't run (claudenot on PATH) — covered instead by the separate multipass deep-review above.Unresolved review findings (test-only, non-blocking)
integrationTests/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 reachingCOMPLETEis covered, andunitTests/sqlTranslator/processAST.test.jsproves 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.