Repository navigation
Let role allowlists grant deploy_component and 19 other refused operations; stop granting catchup - #2809
Merged
Conversation
Gate 1 of the role `operations` allowlist checks the registry entry's api_name, falling back to the handler's name. Twenty entries carried no api_name, so gate 1 checked a camelCase name no role can list: a role listing deploy_component (or restart_service, set_configuration, get_status, ...) validated, saved, and was refused with 403, and gate 2's delegation of an explicitly listed super_user operation never ran. Name the API operation on those entries, and mirror the four alias pairs that creates in MCP's discovery table. get_backup and read_transaction_log stay unnamed on purpose: gate 2 would grant get_backup ahead of its READ check on a whole-database copy, and read_transaction_log lacks read_audit_log's system.hdb_secret guard. A unit test now holds every dispatched built-in operation to being granted by the API names that reach its handler and by no other, and holds MCP discovery to the same answer. Refs #2175. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-ups: drop comments that restated the adjacent code or narrated history, qualify the missing-api_name explanation (a handler whose name is also an API name, like catchup, is still grantable), and exercise deploy_custom_function_project as the deploy_component-only role through the operations API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Code Review
This pull request maps legacy and alias operations to their canonical API names, registers several operations with explicit API names to make them grantable via the operations allowlist, and adds comprehensive tests and documentation for these RBAC permissions. The review feedback highlights a critical privilege escalation vulnerability where the catchup operation, lacking an explicit api_name, defaults to its handler name and becomes grantable to non-super_users, potentially allowing arbitrary database writes. To resolve this, the reviewer suggests registering catchup with an internal name and updating the corresponding tests and design documentation.
The sentence placed catchup next to "is refused", which read as though catchup were refused. It is the one handler without an api_name whose name is also an API name, so a role that lists it is granted it (tracked as #2810). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dawsontoth
marked this pull request as ready for review
September 25, 2026 16:27
github-actions
Bot
requested review from
Ethan-Arrowood,
cb1kenobi,
heskew and
kriszyp
September 25, 2026 16:27
The permission constructor took an optional API name behind an `as any` cast, so a registration could omit it without a type error, and gate 1 then fell back to the handler's own name. That refused every role that listed the operation (#2175) - unless the handler name happened to equal the API name, when it granted it instead, which is how catchup, a legacy operation that writes to any table with no table permission check, stayed delegable (#2810). Type the constructor and make the third argument required: an API name, or null when no allowlist may grant the operation. Omitting it is now TS2554. Gate 1 checks a registered handler only under its entry's name, falling back to the given name only when there is no entry (sql), and names the invoked operation when it refuses a null entry. get_backup, read_transaction_log, catchup and the SQL statement variants are registered with null; login and logout carry their API names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
Reviewed; no blockers found. |
kriszyp
approved these changes
Sep 25, 2026
This was referenced Sep 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A role's
operationsallowlist could never grantdeploy_component,restart_service,set_configuration, the status operations, the other component operations, or a handful more: their authorization registry entries carried no API name, so gate 1 checked the handler's camelCase name (deployComponent), which no role can list. This names the API operation on those 20 entries (24 operation names counting legacy aliases), and makes the name a required constructor argument, so leaving it out is now a compile error (TS2554) rather than a silent refusal. An explicitnullmarks an operation no allowlist may grant:get_backup,read_transaction_log, andcatchup, a legacy operation that applies writes to any table with no table permission check and was grantable only because its handler name happens to equal its API name. Gate 1 now checks a registered handler only under its entry's name. The PR also mirrors the new alias pairs in MCP discovery and adds tests that hold every dispatched operation to its grant. Closes #2175, closes #2810.Found through a customer's CI deploy using OIDC trusted publishing against a cluster on 5.3.0-beta.2: the CI user's role was
{"super_user": false, "operations": ["deploy_component"]}and the trust policy hadoperations: null. Authentication succeeded,deploy_componentanswered 403Operation 'deployComponent' is not permitted for this role's operations configuration, and nohdb_deploymentrow was written. Until a release carries this fix, the workaround is asuper_userCI role, with the minted token narrowed by the trust policy'soperationsscope; note that scope bounds only the Operations API and SQL, not REST/GraphQL (#2201).For the human reviewer
server.registerOperationadds operations per worker at component load and hot deploy, so a handler set compiled earlier goes stale where today's API-name set does not; every expansion site (user cache, inline principals, token scopes) would need the per-thread dispatch map; and it would make every alias spelling a grant, which also changes token scopes (A role that lists a legacy alias spelling (create_schema, search_by_id, deploy_custom_function_project, …) is granted nothing #2807). Reversible: the registry change is data plus a constructor signature.deploy_componentbecause they are the same defect against the same contract. The typed constructor came out of review, to stop this class of bug returning; it catches an omitted name at build time but not a wrong one, which the dispatch guard test catches.restart_serviceis among the 20, but arestart: 'rolling'deploy does not need it: the post-deploy restart runs as an internal job with no permission check. Milestoned v5.3 (main); 5.2 has the same bug, and backporting is your call.null, so no listing grants them. Namingget_backupwould let gate 2 grant it ahead of its READ check, on a handler that copies a whole database,systemincluded;read_transaction_logreads the same records asread_audit_logwithout that operation'ssystem.hdb_secretguard. Making those two safely delegable is get_backup and read_transaction_log cannot be granted through a role's operations allowlist #2806.catchupis a legacy operation nothing calls that applies writes to any table with no table permission check; it was grantable only because its handler name happens to equal its API name, and this PR takes that away (A role that lists catchup can write any table without its CRUD permissions #2810). The alternative, keeping it grantable and adding a table check to its handler, is new code for a path no caller uses.catchupis granted today and refused after upgrade. An inline-assertedsuper_userprincipal carrying an allowlist changes the same way. In a mixed-version cluster, a receiving node still on the old version keeps its old answer. This wants a release note; the docs companion has one.deploy_componentgrantsdeploy_custom_function_projecttoo, but listing only the legacy spelling grants neither, as for the existing four pairs; MCP discovery mirrors that. Changing that is A role that lists a legacy alias spelling (create_schema, search_by_id, deploy_custom_function_project, …) is granted nothing #2807.deploy_componentdelegates code execution in the Harper process; it is an Operations-API least-privilege boundary, not a sandbox. A delegated deployer passing literalcredentialson a node with secret custody gets 403 from the internalset_secretcall, which enforces super_user in-handler, so it must reference a pre-provisioned secret or pass none. The end-to-end test deploys without credentials.Changes
utility/operation_authorization.ts: the constructor is typed with a required API name and all 102as anycasts are gone; the API name on 20 registrations (set_configurationthroughsearch_jobs_by_start_date);nullwith a why-comment onget_backup,read_transaction_log,catchupand the SQL statement variants;login/logoutcarry their names; gate 1 checks a registered handler only under its entry's name and names the invoked operation when it refuses anullentry; and corrected comments that describeddeploy_componentas unmapped (token scope,verifyPerms) orget_backupas delegable (gate 2 TODO).components/mcp/operationVisibility.ts: four alias pairs so discovery answers as dispatch does;components/mcp/DESIGN.mdrecords them, the parity test, and the operations discovery still advertises but dispatch refuses.security/DESIGN.md: which name the allowlist is checked against and why the constructor requires it, why the threenullentries exist, and the OIDC section's registration example updated to the three-argument form.Docs: companion HarperFast/documentation#685 (the canonical-name rule, the operations a listing cannot grant including
catchup, the 5.3 upgrade note, and a least-privilege deploy role next to the trust-policy scope).Verification
End-to-end route: extended an existing integration test.
integrationTests/server/operation-user-rbac.test.tsnow creates the reported role shape ({"super_user": false, "operations": ["deploy_component"]}), deploys a minimal component (fixture) as that user over the operations API, and checks the resulting deployment record reachedsuccessattributed to that user. It also deploys by the legacydeploy_custom_function_projectspelling, and checks that the same role is refuseddrop_componentand a role without the grant is refuseddeploy_component, asserting the allowlist refusal body rather than just the 403. At the final head, 67/67 together withintegrationTests/mcp/operations-role-listing.test.ts,integrationTests/security/choose-operation-authz.test.tsandintegrationTests/components/registered-operation.test.ts. On base30f8ff616the positive deploy fails with the reported403 … Operation 'deployComponent' is not permitted for this role's operations configuration, and the two refusal checks fail because base names the handler (deployComponent,dropComponent) instead of the operation. This does not exercise the OIDC exchange, peer replication, a rolling restart, or the deployed application serving traffic.Build-time guard: dropping the third argument from any registration fails
tsc --project tsconfig.build.jsonwithTS2554: Expected 3 arguments, but got 2(checked by removing it from thelogoutregistration, then restoring it). CI's build step runs thattsc.Unit, run as
npx mochaon the three files below: 219 passing on the branch; on base, 50 failing, every one an assertion failure (no setup errors).unitTests/utility/operation_authorization.test.js: a guard over the realOPERATION_FUNCTION_MAP(the handlerchooseOperationhandsverifyPerms, job handler included) that every dispatched built-in is granted by listing the API names reaching its handler and refused by a list of every other operation name; thatget_backup,read_transaction_logandcatchupstay ungrantable even when the allowlist also holds their handler names, with the refusal naming the operation; and that MCP'scanRoleInvokeOperationgives the same answer as gate 1 for every single listing. Per-operation tests that each of the 20 is allowed when listed and refused, with the operation's own name in the message, when not; and that each legacy alias is allowed when its canonical name is listed.unitTests/security/tokenOperationScope.test.js: a token scope over a role granted onlydeploy_component(matching, absent, mismatched, and empty scope), since gate 2 returns early for that grant; stale comments that calleddeployComponentunmapped are corrected.unitTests/components/mcp/toolRegistry.test.js: the four new alias pairs in discovery, and that the legacy spelling alone grants neither.Gates, run before the typed-constructor commit and repeated where it could matter:
lint:required,check:design-docsandprettier --checkpass at the final head, andunitTests/server/serverHelpers/serverUtilities.test.jspasses with the three files above (317).test:unit:main(withapplicationSpawn.test.jsexcluded, which hangs on this machine, and under a sentinelHOME) failed 26 tests on the branch and 74 on base; the branch's failures were a subset of base's apart from oneEntryHandlertimeout, which passes 40/40 when that file runs alone, twice. The extra base failures were the new tests; the shared 25 are environment failures on this machine (git-over-HTTP fixtures, spawn credentials,/tmpvs/private/tmppaths).test:unit:resources: 3,044 passing, 4 failing; three fail identically on base (sourceApplyConflictRetry.test.js, whose premise that a flush strands the snapshot withERR_TRY_AGAINdoes not hold on this machine), and the fourth, intxn-tracking.test.js, passes 29/29 when that file runs alone, twice.test:integration:all: 2,114 passed, 25 failed across eight files under full-suite load. Re-running those eight files as a set on branch and base, the branch fails exactly what base fails (shutdown-drain-e2eA and B,record-lock-concurrency's concurrent lock+increment, andlog-rotation-write-path's "stops rotating in every HTTP worker"), plus one morelog-rotation-write-pathtest that passed when that file was re-run alone on both;date-queries,blob-reclaim-removal-paths,longtxn-secondary-index,crosstable-index-scan-completenessanddeploy-from-githubpass on the branch outside full-suite load.Complexity: medium
Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-kimi,cursor-muse; rounds=2; full=1 @ 8aef436
Human-Review-Need: 3 (decisions: catchup-made-ungrantable, required-api-name-constructor, canonical-only-alias-grants, upgrade-activates-inert-grants, ungrantable-by-null) @ 8aef436