Skip to content

Let role allowlists grant deploy_component and 19 other refused operations; stop granting catchup - #2809

Merged
kriszyp merged 4 commits into
mainfrom
fix-role-allowlist-su-only-api-names
Sep 25, 2026
Merged

kriszyp merged 4 commits into
mainfrom
fix-role-allowlist-su-only-api-names

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

A role's operations allowlist could never grant deploy_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 explicit null marks an operation no allowlist may grant: get_backup, read_transaction_log, and catchup, 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 had operations: null. Authentication succeeded, deploy_component answered 403 Operation 'deployComponent' is not permitted for this role's operations configuration, and no hdb_deployment row was written. Until a release carries this fix, the workaround is a super_user CI role, with the minted token narrowed by the trust policy's operations scope; note that scope bounds only the Operations API and SQL, not REST/GraphQL (#2201).

For the human reviewer

  1. The planning framing was resolved, not cleared. Codex rated the approach sound; Gemini proposed compiling each role's allowlist into a set of handler names at role load instead of naming the API on each registry entry. Overruled: roles are expanded once when cached, while server.registerOperation adds 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.
  2. Requirement. As specified, yes: the users-and-roles docs promise that listing a super_user operation grants it, and the only workaround is a super_user identity, which keeps full REST/GraphQL data access. I named all 20 rather than only deploy_component because 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_service is among the 20, but a restart: '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.
  3. Three operations are null, so no listing grants them. Naming get_backup would let gate 2 grant it ahead of its READ check, on a handler that copies a whole database, system included; read_transaction_log reads the same records as read_audit_log without that operation's system.hdb_secret guard. Making those two safely delegable is get_backup and read_transaction_log cannot be granted through a role's operations allowlist #2806. catchup is 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.
  4. Upgrade changes what stored listings do, with no role edit. A stored role that lists any of these 20 names is refused today and granted after upgrade; none is in a built-in group, so only explicit listings change. A role that lists catchup is granted today and refused after upgrade. An inline-asserted super_user principal 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.
  5. Aliases stay canonical-only: listing deploy_component grants deploy_custom_function_project too, 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.
  6. Delegating deploy_component delegates code execution in the Harper process; it is an Operations-API least-privilege boundary, not a sandbox. A delegated deployer passing literal credentials on a node with secret custody gets 403 from the internal set_secret call, 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

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.ts now 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 reached success attributed to that user. It also deploys by the legacy deploy_custom_function_project spelling, and checks that the same role is refused drop_component and a role without the grant is refused deploy_component, asserting the allowlist refusal body rather than just the 403. At the final head, 67/67 together with integrationTests/mcp/operations-role-listing.test.ts, integrationTests/security/choose-operation-authz.test.ts and integrationTests/components/registered-operation.test.ts. On base 30f8ff616 the positive deploy fails with the reported 403 … 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.json with TS2554: Expected 3 arguments, but got 2 (checked by removing it from the logout registration, then restoring it). CI's build step runs that tsc.

Unit, run as npx mocha on the three files below: 219 passing on the branch; on base, 50 failing, every one an assertion failure (no setup errors).

Gates, run before the typed-constructor commit and repeated where it could matter: lint:required, check:design-docs and prettier --check pass at the final head, and unitTests/server/serverHelpers/serverUtilities.test.js passes with the three files above (317). test:unit:main (with applicationSpawn.test.js excluded, which hangs on this machine, and under a sentinel HOME) failed 26 tests on the branch and 74 on base; the branch's failures were a subset of base's apart from one EntryHandler timeout, 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, /tmp vs /private/tmp paths). test:unit:resources: 3,044 passing, 4 failing; three fail identically on base (sourceApplyConflictRetry.test.js, whose premise that a flush strands the snapshot with ERR_TRY_AGAIN does not hold on this machine), and the fourth, in txn-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-e2e A and B, record-lock-concurrency's concurrent lock+increment, and log-rotation-write-path's "stops rotating in every HTTP worker"), plus one more log-rotation-write-path test that passed when that file was re-run alone on both; date-queries, blob-reclaim-removal-paths, longtxn-secondary-index, crosstable-index-scan-completeness and deploy-from-github pass 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

dawsontoth and others added 2 commits September 25, 2026 11:28
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>

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

Comment thread utility/operation_authorization.ts Outdated
Comment thread unitTests/utility/operation_authorization.test.js Outdated
Comment thread security/DESIGN.md Outdated
Comment thread components/mcp/DESIGN.md Outdated
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
dawsontoth marked this pull request as ready for review 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>
@claude

claude Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@dawsontoth dawsontoth changed the title Let a role's operations allowlist grant deploy_component, restart_service, and 18 other operations it refused Let role allowlists grant deploy_component and 19 other refused operations; stop granting catchup Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants