While building the Studio UI for permission.operations (HarperFast/studio#1627, shipped in HarperFast/studio#1628), I traced the allowlist gate through the source and believe a set of grants that add_role/alter_role accept can never match at authorization time. Static reading of main @ 21043f9 and v5.2.2 — not yet reproduced against a live instance, so please correct me if I've misread the dispatch.
The mechanism
The allowlist gate resolves the operation's name like this (utility/operation_authorization.ts:635):
const opApiName = requiredPermissions.get(op)?.api_name ?? op;
if (!allowedOps.has(opApiName)) { /* deny */ }
op is the handler function's camelCase name: serverUtilities.ts passes the handler function itself to verifyPerms (const verifyPermsResult = opAuth.verifyPerms(operation_json, functionToCheck)), and verifyPerms does op = operation.name for functions. allowedOps holds the snake_case names that validateOperations accepted at role-write time.
So for any requiredPermissions entry registered without the api_name third argument, opApiName falls back to the camelCase handler name — which can never equal a granted snake_case name. The grant validates, saves, and is silently gate-inert: gate 1 denies the operation even though it is explicitly listed, and the gate-2 SU delegation never fires.
Affected registrations (no api_name, SU-only, wire name ≠ handler name)
From utility/operation_authorization.ts on main:
- Components:
deployComponent (deploy_component — the headline example in studio#1627), addComponent, dropComponent, packageComponent, setComponentFile, setCustomFunction, dropCustomFunction, dropCustomFunctionProject
- Status:
status.get/set/clear (get_status, set_status, clear_status)
- System:
configUtils.setConfiguration (set_configuration), restart.restartService (restart_service), npmUtilities.installModules (install_node_modules)
- Data/logs:
getBackup (get_backup), schema.cleanupOrphanBlobs (cleanup_orphan_blobs), transactionLog.readTransactionLog (read_transaction_log), deleteTransactionLogsBefore, delete_.deleteFilesBefore, delete_.deleteAuditLogsBefore
- Jobs:
handleGetJobsByStartDate (search_jobs_by_start_date)
(catchup also lacks api_name but its handler name equals the wire name, so the fallback happens to work. login/logout bypass verifyPerms entirely and are unaffected.)
Notably, the gate's own TODO comment says "get_backup remains the one that relies solely on this gate" — i.e. the intent is that these are delegable; the registration just never wired the name.
Ops with no requiredPermissions entry at all (e.g. audit_node_modules, add_node, update_node, set_node_replication, purge_stream, delete_job, update_job, delete_records_before) hit the same ?? op fallback and are equally ungrantable — worth sweeping in the same pass if any are meant to be user-addressable.
Related gap: sql bypasses the allowlist entirely
serverUtilities.ts routes operation === 'sql' to checkASTPermissions/verifyPermsAST instead of verifyPerms, and verifyPermsAST has no allowlist check at all. Two consequences:
- A role restricted to e.g.
operations: ["insert"] can still run arbitrary sql (subject to table CRUD perms) — the "deny anything unlisted" contract doesn't hold for sql.
- Granting
sql (or read_only, which includes it) "works" only because sql is never gate-checked in the first place.
Related nit: legacy alias spellings
validateOperations accepts both spellings of aliased ops (describe_database/describe_schema, create_schema/create_database, search_by_id/search_by_hash, *_custom_function_project/*_component), but the gate resolves to the single api_name on the registration — so granting the other spelling is gate-inert too. The predefined groups defensively include both spellings, which papers over this for group users but not for individual grants.
Suggested fix
- Pass
terms.OPERATIONS_ENUM.* as api_name on every user-addressable registration (mechanical), or normalize camelCase→snake_case in the gate fallback.
- Decide whether
sql should flow through gate 1 (I'd argue yes — the allowlist reads as a hard contract) and add it to the AST path if so.
- An integration test in
operation-user-rbac.test.ts that grants one of the previously-unwired names (e.g. operations: ["deploy_component"]) and asserts the op passes gate 1 — today's tests only exercise ops that happen to have api_name set.
- For aliases: either expand alias pairs in
expandOperationsPerms or document that grants must use the canonical spelling.
Why it matters downstream
Studio's role editor (HarperFast/studio#1628) offers every name validateOperations accepts, so today it can produce roles whose grants look right, save cleanly, and silently don't work — including the deploy-only CI role that motivated studio#1627.
🤖 Filed by Claude on behalf of @dawsontoth
While building the Studio UI for
permission.operations(HarperFast/studio#1627, shipped in HarperFast/studio#1628), I traced the allowlist gate through the source and believe a set of grants thatadd_role/alter_roleaccept can never match at authorization time. Static reading ofmain@ 21043f9 andv5.2.2— not yet reproduced against a live instance, so please correct me if I've misread the dispatch.The mechanism
The allowlist gate resolves the operation's name like this (
utility/operation_authorization.ts:635):opis the handler function's camelCase name:serverUtilities.tspasses the handler function itself toverifyPerms(const verifyPermsResult = opAuth.verifyPerms(operation_json, functionToCheck)), andverifyPermsdoesop = operation.namefor functions.allowedOpsholds the snake_case names thatvalidateOperationsaccepted at role-write time.So for any
requiredPermissionsentry registered without theapi_namethird argument,opApiNamefalls back to the camelCase handler name — which can never equal a granted snake_case name. The grant validates, saves, and is silently gate-inert: gate 1 denies the operation even though it is explicitly listed, and the gate-2 SU delegation never fires.Affected registrations (no
api_name, SU-only, wire name ≠ handler name)From
utility/operation_authorization.tsonmain:deployComponent(deploy_component— the headline example in studio#1627),addComponent,dropComponent,packageComponent,setComponentFile,setCustomFunction,dropCustomFunction,dropCustomFunctionProjectstatus.get/set/clear(get_status,set_status,clear_status)configUtils.setConfiguration(set_configuration),restart.restartService(restart_service),npmUtilities.installModules(install_node_modules)getBackup(get_backup),schema.cleanupOrphanBlobs(cleanup_orphan_blobs),transactionLog.readTransactionLog(read_transaction_log),deleteTransactionLogsBefore,delete_.deleteFilesBefore,delete_.deleteAuditLogsBeforehandleGetJobsByStartDate(search_jobs_by_start_date)(
catchupalso lacksapi_namebut its handler name equals the wire name, so the fallback happens to work.login/logoutbypassverifyPermsentirely and are unaffected.)Notably, the gate's own TODO comment says "get_backup remains the one that relies solely on this gate" — i.e. the intent is that these are delegable; the registration just never wired the name.
Ops with no
requiredPermissionsentry at all (e.g.audit_node_modules,add_node,update_node,set_node_replication,purge_stream,delete_job,update_job,delete_records_before) hit the same?? opfallback and are equally ungrantable — worth sweeping in the same pass if any are meant to be user-addressable.Related gap:
sqlbypasses the allowlist entirelyserverUtilities.tsroutesoperation === 'sql'tocheckASTPermissions/verifyPermsASTinstead ofverifyPerms, andverifyPermsASThas no allowlist check at all. Two consequences:operations: ["insert"]can still run arbitrarysql(subject to table CRUD perms) — the "deny anything unlisted" contract doesn't hold forsql.sql(orread_only, which includes it) "works" only becausesqlis never gate-checked in the first place.Related nit: legacy alias spellings
validateOperationsaccepts both spellings of aliased ops (describe_database/describe_schema,create_schema/create_database,search_by_id/search_by_hash,*_custom_function_project/*_component), but the gate resolves to the singleapi_nameon the registration — so granting the other spelling is gate-inert too. The predefined groups defensively include both spellings, which papers over this for group users but not for individual grants.Suggested fix
terms.OPERATIONS_ENUM.*asapi_nameon every user-addressable registration (mechanical), or normalize camelCase→snake_case in the gate fallback.sqlshould flow through gate 1 (I'd argue yes — the allowlist reads as a hard contract) and add it to the AST path if so.operation-user-rbac.test.tsthat grants one of the previously-unwired names (e.g.operations: ["deploy_component"]) and asserts the op passes gate 1 — today's tests only exercise ops that happen to haveapi_nameset.expandOperationsPermsor document that grants must use the canonical spelling.Why it matters downstream
Studio's role editor (HarperFast/studio#1628) offers every name
validateOperationsaccepts, so today it can produce roles whose grants look right, save cleanly, and silently don't work — including the deploy-only CI role that motivated studio#1627.🤖 Filed by Claude on behalf of @dawsontoth