diff --git a/AGENTS.md b/AGENTS.md
index a7b071f24..3814f3f93 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -582,24 +582,39 @@ execute relationship joins).
Harper 5.0+ roles accept an `operations` allowlist. Almost every intuitive reading of it is wrong,
and each of these cost a review round on #1628 — verify against the Harper source before changing
-`src/features/instance/config/roles/operations/**` or `src/integrations/api/localRolePermission.ts`.
-
-- **It does not restrict super users.** `verifyPerms` returns early for `super_user`
- ("admins can do (almost) anything") _before_ the allowlist gate. The allowlist is a way to scope
- a NON-super role (gate 2 then lets it hold otherwise-SU operations), not a way to narrow an admin.
+`src/features/instance/config/roles/operations/**` or `src/integrations/api/localRolePermission.ts`,
+**at the tag you mean**. Harper changed gate 1 twice in 5.3, and `resolveGrantBand` turns a version
+into one of three bands (`pre-5.3`, `5.3-early`, `5.3`) so callers don't parse SemVer per operation:
+from v5.3.0-alpha.1 (harper `f8a5aa90a`) `verifyPerms` runs the gate ahead of every privilege
+early-return and `chooseOperation` runs it on `sql`; from v5.3.0-beta.3 every entry names its grant.
+`allowlistGatesEveryPath(band)` is the first change.
+
+- **The list never narrows a stored super user, for a different reason on each side.** Through 5.2.x
+ `verifyPerms` returns early for `super_user` ("admins can do (almost) anything") _before_ the gate.
+ From v5.3.0-alpha.1 the gate comes first ("must run before EVERY privilege early-return"), and a
+ list beside `super_user` would be enforced, but `validateNoSUPerms` (next bullet) refuses to store
+ one. Either way the allowlist scopes a NON-super role (gate 2 then lets it hold otherwise-SU
+ operations).
- **`super_user`/`cluster_user` + `operations` cannot even be saved.** `validateNoSUPerms` rejects
any permission with more than one key that sets either flag, so the combination 400s. Strip the
allowlist for those roles rather than sending it.
-- **`structure_user` is different and IS gated** — only `create_table`/`create_attribute`/
- `drop_table`/`drop_attribute` (plus create/drop database for the boolean form) short-circuit. The
- array form scopes that carve-out to its listed databases and never reaches create/drop database.
-- **Which names the gate matches depends on the version.** Before 5.3, 20 entries omit `api_name`,
+- **`structure_user` DDL skips the list only before 5.3.** Through 5.2.x `create_table`/
+ `create_attribute`/`drop_table`/`drop_attribute` (plus create/drop database for the boolean form)
+ return before the gate. From v5.3.0-alpha.1 the gate runs first, so an allowlisted structure user
+ runs that DDL only when it is listed. On both, the array form keeps the DDL to its listed databases
+ (listing `drop_table` cannot reach another one), and its flag never covers create/drop database.
+ `structureUserDdlScope` reads the shape only; the editor's copy takes the band.
+- **Which names the gate matches depends on the version.** Before v5.3.0-beta.3, 20 entries omit `api_name`,
so the gate compares a camelCase handler name against a snake_case grant and never matches
(`deploy_component`, `get_status`, `registration_info`, …; HarperFast/harper#2175). From
v5.3.0-beta.3 every entry names its grant, and `get_backup`, `read_transaction_log` and `catchup`
name `null` on purpose, so no allowlist reaches them. The catalog records this per entry (`gate`), and
- callers resolve the instance version into a `GrantBand` once. `sql` is worse: it routes to
- `verifyPermsAST`, which never consults the allowlist at all.
+ callers resolve the instance version into a `GrantBand` once.
+- **`sql` ignores the list before 5.3 and needs it from v5.3.0-alpha.1.** Through 5.2.x
+ `chooseOperation` sends it straight to `checkASTPermissions`, which never consults the allowlist,
+ so listing or omitting it changes nothing. From alpha.1 it calls `verifyOperationsAllowlist` on
+ `sql` first, so a list without `sql` (or a group carrying it, like `read_only`) denies every SQL
+ statement. Table permissions apply on both. The catalog marks it `outOfGate: 'pre-5.3'`.
- **Alias spellings are inert too** (`describe_database`, `search_by_id`, `create_schema`, …): both
spellings dispatch to one handler whose entry carries only the canonical `api_name`.
- **A non-iterable value takes the instance down.** `listUsers` expands every assigned role's
@@ -615,22 +630,28 @@ and each of these cost a review round on #1628 — verify against the Harper sou
Practical consequence for the UI: never describe a restriction the server does not enforce, and
never advise "fixing" an `operations` value in place — both mistakes shipped and were caught in
review. `classifyOperationsValue` needs the instance version because the same shape means different
-things on either side of the 5.0.0-alpha.8 floor (the feature's first tagged build, not 5.0.0).
+things on either side of the 5.0.0-alpha.8 floor (the feature's first tagged build, not 5.0.0). The
+editor's structure-user and SQL sentences need the band for the same reason: each is true on one side
+of v5.3.0-alpha.1 only.
### Which of those the UI gating turns on
The two facts that decide Studio's permission hooks (`src/hooks/checkOperationPermission.ts`):
-- **Gate admin chrome on the allowlist and you hide UI that works.** Because super users never reach
- the gate, `useInstanceManagePermission` and `useInstanceBrowseManagePermission` are deliberately NOT
- gated. Note browse-manage is only half short-circuited: its DDL half is, but the application-editor
- operations behind it (`set_component_file`, `drop_component`, `deploy_component`, `set_env_value`,
- `restart_service`) are not in `STRUCTURE_USER_OPS`, so for a non-super role they do reach the gate —
- where an allowlist listing them _grants_ them via gate 2. Denying that half on the allowlist would
- therefore hide UI a scoped role can legitimately use.
-- **`isElevatedRole` is the wrong question for a specific operation.** It answers "is the list
- unenforceable anywhere for this role", which is what the editor warns about. For DML,
- `structure_user` and `cluster_user` are still gated.
+- **Gate admin chrome on the allowlist and you hide UI that works.** No stored super user fails the
+ gate on any version (see above), so `useInstanceManagePermission` is deliberately NOT gated.
+ `useInstanceBrowseManagePermission` isn't either, but only pre-5.3 makes that exact: there its DDL
+ half returns before the gate for a structure user. From v5.3.0-alpha.1 nothing does, so an
+ allowlisted structure user is offered DDL the server refuses unless the list names it. The hooks
+ can't see the version, and gating them on the list would hide DDL that works on 5.2, so they stay
+ open and the server's 403 names the operation (HarperFast/studio#1894 tracks per-operation checks).
+ The application-editor operations behind browse-manage (`set_component_file`, `drop_component`,
+ `deploy_component`, `set_env_value`, `restart_service`) are not in `STRUCTURE_USER_OPS`, so for a
+ non-super role they reach the gate on every version — where an allowlist listing them _grants_ them
+ via gate 2. Denying that half on the allowlist would hide UI a scoped role can legitimately use.
+- **Only `super_user` short-circuits a specific operation.** For DML, `structure_user` and
+ `cluster_user` are gated on every version, so `checkTableOperationsAllowed` exempts the first flag
+ alone. Asking whether a role carries an elevating flag is the editor's question, not this one.
Also two different expansions are correct for two different questions, and sharing one silently broke
the second once: `expandEffectiveOperations` folds alias spellings together to describe a role's
@@ -648,6 +669,12 @@ denied. `testUtils.preTestPrep()` + `testUtils.setGlobalSchema(...)` provide the
`verifyPermsAST` (to show SQL skipping the gate). Run with `npx mocha unitTests/utility/
- {restricted
- ? 'Only the operations below are allowed — anything unlisted is denied. Listing an operation that '
+ {!restricted
+ ? 'No operation-level restriction. Add one to limit this role to an explicit list of API operations '
+ + '(for example a deploy-only CI role).'
+ : gatesEveryPath
+ ? 'Only the operations below are allowed — anything unlisted is denied, SQL included: SQL statements '
+ + 'need sql (or a group with it) listed here, and are then checked against table permissions as '
+ + 'well. Listing an operation that normally requires super_user deliberately grants it to this role.'
+ : 'Only the operations below are allowed — anything unlisted is denied. Listing an operation that '
+ 'normally requires super_user deliberately grants it to this role. SQL statements are authorized '
- + 'against table permissions instead, so this list does not restrict them.'
- : 'No operation-level restriction. Add one to limit this role to an explicit list of API operations '
- + '(for example a deploy-only CI role).'}
+ + 'against table permissions instead, so this list does not restrict them.'}
- This role is a structure user, so it reaches{' '} - {STRUCTURE_USER_DDL_OPERATIONS.join(', ')} - {structureUserDdl === true - ? ' (and create/drop database) on any database regardless of the list below.' - : ( - <> - {' '}on {structureUserDdl.join(', ')}{' '} - regardless of the list below — and listing them here cannot reach another database. - > - )} Every other operation is still gated by the list. -
- )} + {structureUserDdl && restricted && (gatesEveryPath + ?+ This role is a structure user, so it reaches{' '} + {STRUCTURE_USER_DDL_OPERATIONS.join(', ')} + {structureUserDdl === true + ? ' (and create/drop database) on any database regardless of the list below.' + : ( + <> + {' '}on {structureUserDdl.join(', ')}{' '} + regardless of the list below — and listing them here cannot reach another database. + > + )} Every other operation is still gated by the list. +
+ ))} {restricted && ( <> @@ -200,9 +213,10 @@ export function OperationsAllowlistEditor({ ? ({value?.length ? 'Every entry here is inert' : 'Nothing selected'}{' '} - — this role cannot run any Operations API call{structureUserDdl ? ', except the DDL noted above' : ''} - {' '} - (SQL is gated by table permissions, not this list). + — this role cannot run any Operations API call{gatesEveryPath + ? `, not even SQL${structureUserDdl ? ' or its structure-user DDL' : ''}.` + : `${structureUserDdl ? ', except the DDL noted above' : ''}` + + ' (SQL is gated by table permissions, not this list).'}
) : ( @@ -218,6 +232,32 @@ export function OperationsAllowlistEditor({ ); } +/** The flag still scopes which databases its DDL may touch, but that DDL no longer skips the list. */ +function GatedStructureUserNote({ scope, effective }: { scope: true | string[]; effective: readonly string[] }) { + const covered = scope === true + ? [...STRUCTURE_USER_DDL_OPERATIONS, ...STRUCTURE_USER_DATABASE_OPERATIONS] + : STRUCTURE_USER_DDL_OPERATIONS; + const unlisted = covered.filter((name) => !effective.includes(name)); + return ( +0 ? 'text-warning' : 'text-muted-foreground')}> + This role is a structure user{scope === true ? '' : ( + <> + {' '}on {scope.join(', ')} + > + )}, but on this Harper version the list below gates its DDL too: it can run{' '} + {covered.join(', ')} + {scope === true + ? ' only when they are listed.' + : ' on those databases only when they are listed, and listing them cannot reach another database.'} + {unlisted.length > 0 && ( + <> + {' '}Not listed, so Harper refuses them: {unlisted.join(', ')}. + > + )} +
+ ); +} + function GroupCheckbox({ group, note, @@ -449,10 +489,8 @@ function OperationChip({ : info?.nonDelegable ? 'Always requires an actual super_user role; listing it cannot delegate the operation to a ' + 'non-super_user role.' - : info?.outOfGate - ? info.caveat - : info?.caveat - ? info.caveat + : isOutOfGate(name, band) + ? info?.caveat : info?.su ? 'Normally requires super_user; listing it grants it to this role.' : info diff --git a/src/features/instance/config/roles/operations/RoleOperationsSummary.test.tsx b/src/features/instance/config/roles/operations/RoleOperationsSummary.test.tsx index 9dfa873ba..89e493c8a 100644 --- a/src/features/instance/config/roles/operations/RoleOperationsSummary.test.tsx +++ b/src/features/instance/config/roles/operations/RoleOperationsSummary.test.tsx @@ -14,6 +14,7 @@ vi.mock('@/features/instance/config/roles/operations/useOperationsAllowlistSuppo })); const ON_5_3: AllowlistVersion = { supported: true, band: '5.3' }; +const BEFORE_5_3: AllowlistVersion = { supported: true, band: 'pre-5.3' }; beforeEach(() => allowlistVersion.mockReturnValue(ON_5_3)); @@ -53,11 +54,13 @@ describe('RoleOperationsSummary', () => { }); it('notes the DDL carve-out for a structure_user role instead of calling the list inert', () => { + allowlistVersion.mockReturnValue(BEFORE_5_3); render({(getOperationsAllowlist(permission) ?? []).length ? "Every entry in this role's operations allowlist is inert" - : "This role's operations allowlist is empty"}{' '} - — users with it cannot run any Operations API call (SQL is authorized by table permissions instead, so it is - unaffected) - {ddlScope ? '' : '.'} - {ddlNote} + : "This role's operations allowlist is empty"} — users with it cannot run any Operations API call + {gatesEveryPath + ? `, not even SQL${ddlScope ? ' or its structure-user DDL' : ''}.` + : ( + <> + {' '}(SQL is authorized by table permissions instead, so it is unaffected) + {ddlScope ? '' : '.'} + {ddlNote} + > + )}
); } diff --git a/src/features/instance/config/roles/operations/operationsCatalog.test.ts b/src/features/instance/config/roles/operations/operationsCatalog.test.ts index bc8bd86c0..47661003a 100644 --- a/src/features/instance/config/roles/operations/operationsCatalog.test.ts +++ b/src/features/instance/config/roles/operations/operationsCatalog.test.ts @@ -1,4 +1,5 @@ import { + allowlistGatesEveryPath, canonicalOperationName, expandEffectiveOperations, gateVerdict, @@ -8,6 +9,7 @@ import { GrantBand, isInertGrant, isOperationGroupName, + isOutOfGate, OPERATION_CATALOG, OPERATION_CATEGORIES, OPERATION_GROUPS, @@ -178,20 +180,14 @@ describe('expandEffectiveOperations', () => { // Harper's group lists both spellings of two alias pairs (describe_schema/describe_database, // search_by_hash/search_by_id), but each pair reaches one handler — counting both would // overstate the role's reach against the chips shown beside the count. - expect(expandEffectiveOperations(['read_only'], '5.3')).toEqual([ - 'describe_all', - 'describe_metric', - 'describe_schema', - 'describe_table', - 'get_analytics', - 'get_job', - 'list_metrics', - 'search', - 'search_by_conditions', - 'search_by_hash', - 'search_by_value', - 'user_info', - ]); + expect(expandEffectiveOperations(['read_only'], 'pre-5.3')).toEqual(READ_ONLY_PRE_5_3); + }); + + it('counts the sql member of read_only once the list gates the SQL path', () => { + // Harper's read_only carries sql, which the gate checks from 5.3.0-alpha.1 on. + for (const band of ['5.3-early', '5.3'] as const) { + expect(expandEffectiveOperations(['read_only'], band), band).toEqual([...READ_ONLY_PRE_5_3, 'sql'].sort()); + } }); it('does not credit a directly granted alias with its canonical operation', () => { @@ -202,9 +198,15 @@ describe('expandEffectiveOperations', () => { }); it('leaves out entries the allowlist does not govern', () => { - // set_secret and the trust-policy operations self-enforce super_user; sql is authorized on its - // own path, so listing any of them changes nothing about what the role can do. - expect(expandEffectiveOperations(['set_secret', 'sql', 'add_oidc_trust', 'list_oidc_trust'], '5.3')).toEqual([]); + // set_secret and the trust-policy operations self-enforce super_user, so listing them changes + // nothing about what the role can do. + expect(expandEffectiveOperations(['set_secret', 'add_oidc_trust', 'list_oidc_trust'], '5.3')).toEqual([]); + }); + + it('counts a direct sql grant only where the gate checks the SQL path', () => { + expect(expandEffectiveOperations(['sql'], 'pre-5.3')).toEqual([]); + expect(expandEffectiveOperations(['sql'], '5.3-early')).toEqual(['sql']); + expect(expandEffectiveOperations(['sql'], '5.3')).toEqual(['sql']); }); it('de-duplicates group members against explicit names and passes unknown names through', () => { @@ -223,11 +225,13 @@ describe('expandEffectiveOperations', () => { it('counts the deploy operations from 5.3 and not before', () => { const deployRole = ['deploy_component', 'get_job']; expect(expandEffectiveOperations(deployRole, 'pre-5.3')).toEqual(['get_job']); + // The early 5.3 prereleases already gate first but have not named the deploy entries yet. + expect(expandEffectiveOperations(deployRole, '5.3-early')).toEqual(['get_job']); expect(expandEffectiveOperations(deployRole, '5.3')).toEqual(['deploy_component', 'get_job']); }); it('never counts the operations Harper keeps out of every allowlist', () => { - for (const band of ['pre-5.3', '5.3'] as const) { + for (const band of ['pre-5.3', '5.3-early', '5.3'] as const) { expect(expandEffectiveOperations(['get_backup', 'read_transaction_log'], band)).toEqual([]); } }); @@ -237,6 +241,22 @@ describe('expandEffectiveOperations', () => { }); }); +// read_only's members less the two folded alias pairs and sql, which pre-5.3 Harper does not gate. +const READ_ONLY_PRE_5_3 = [ + 'describe_all', + 'describe_metric', + 'describe_schema', + 'describe_table', + 'get_analytics', + 'get_job', + 'list_metrics', + 'search', + 'search_by_conditions', + 'search_by_hash', + 'search_by_value', + 'user_info', +]; + // Harper's own record of which names gate 1 grants by name (unitTests/utility/operation_authorization.test.js // at v5.3.0, GRANTED_BY_NAME): the twenty entries that were unnamed before 5.3. const NAMED_FROM_5_3 = [ @@ -269,8 +289,14 @@ describe('resolveGrantBand', () => { } }); - it('keeps the 5.3 prereleases that predate it, and every 5.2, on the old side', () => { - for (const version of ['5.3.0-alpha.1', '5.3.0-beta.1', '5.3.0-beta.2', '5.2.15', '5.0.0']) { + it('puts the 5.3 prereleases that gate first but predate the names in their own band', () => { + for (const version of ['5.3.0-alpha.1', '5.3.0-beta.1', '5.3.0-beta.2']) { + expect(resolveGrantBand(version), version).toBe('5.3-early'); + } + }); + + it('keeps every 5.2 and earlier on the old side', () => { + for (const version of ['5.2.15', '5.2.99', '5.0.0-alpha.8', '5.0.0']) { expect(resolveGrantBand(version), version).toBe('pre-5.3'); } }); @@ -282,12 +308,22 @@ describe('resolveGrantBand', () => { }); }); +describe('allowlistGatesEveryPath', () => { + it('is the gate-order change at 5.3.0-alpha.1, not the naming change at beta.3', () => { + expect(allowlistGatesEveryPath('pre-5.3')).toBe(false); + expect(allowlistGatesEveryPath('5.3-early')).toBe(true); + expect(allowlistGatesEveryPath('5.3')).toBe(true); + }); +}); + describe('gateVerdict', () => { it('marks exactly the twenty newly named entries as needing 5.3', () => { - const needs53 = OPERATION_CATALOG.filter((operation) => gateVerdict(operation.name, 'pre-5.3') === 'needs-5.3') - .map((operation) => operation.name) - .sort(); - expect(needs53).toEqual(NAMED_FROM_5_3); + for (const band of ['pre-5.3', '5.3-early'] as const) { + const needs53 = OPERATION_CATALOG.filter((operation) => gateVerdict(operation.name, band) === 'needs-5.3') + .map((operation) => operation.name) + .sort(); + expect(needs53, band).toEqual(NAMED_FROM_5_3); + } for (const name of NAMED_FROM_5_3) { expect(gateVerdict(name, '5.3'), name).toBe('grants'); } @@ -299,6 +335,8 @@ describe('gateVerdict', () => { .map((operation) => operation.name) .sort(); expect(never('pre-5.3')).toEqual(['get_backup', 'read_transaction_log']); + // catchup still matches through the handler-name fallback until beta.3 names it null. + expect(never('5.3-early')).toEqual(['get_backup', 'read_transaction_log']); expect(never('5.3')).toEqual(['catchup', 'get_backup', 'read_transaction_log']); }); @@ -312,6 +350,21 @@ describe('isInertGrant', () => { expect(isInertGrant('deploy_component', 'pre-5.3')).toBe(true); expect(isInertGrant('deploy_component', '5.3')).toBe(false); }); + + it('calls sql inert only where the SQL path skips the gate', () => { + expect(isInertGrant('sql', 'pre-5.3')).toBe(true); + expect(isInertGrant('sql', '5.3-early')).toBe(false); + expect(isInertGrant('sql', '5.3')).toBe(false); + }); +}); + +describe('isOutOfGate', () => { + it('places sql outside the gate before 5.3 only, and nothing else in any band', () => { + for (const band of ['pre-5.3', '5.3-early', '5.3'] as const) { + const outside = OPERATION_CATALOG.filter((operation) => isOutOfGate(operation.name, band)).map((o) => o.name); + expect(outside, band).toEqual(band === 'pre-5.3' ? ['sql'] : []); + } + }); }); describe('canonicalOperationName', () => { diff --git a/src/features/instance/config/roles/operations/operationsCatalog.ts b/src/features/instance/config/roles/operations/operationsCatalog.ts index 8288111b4..78287bfe7 100644 --- a/src/features/instance/config/roles/operations/operationsCatalog.ts +++ b/src/features/instance/config/roles/operations/operationsCatalog.ts @@ -14,10 +14,11 @@ import { wasAReleasedBeforeB } from '@/lib/string/wasAReleasedBeforeB'; * array denies every operation. * - A listed operation that normally requires super_user is treated as a deliberate admin grant * and allowed without super_user (`su` below marks those). - * - super_user reaches operations without consulting the list, and cannot even hold one: - * validateNoSUPerms rejects a permission that sets super_user/cluster_user alongside other keys. - * structure_user roles DO go through the gate for everything except DDL. - * - `sql` never reaches this gate at all; it is authorized against table permissions. + * - super_user cannot hold a list: validateNoSUPerms rejects a permission that sets + * super_user/cluster_user alongside other keys. + * - Before 5.3 the gate runs after verifyPerms' super_user and structure_user returns, so a + * structure user's DDL skips it, and `sql` never reaches it. From 5.3.0-alpha.1 it runs first and + * on the SQL path too (see `GrantBand`). */ export interface GrantableOperation { name: string; @@ -35,32 +36,56 @@ export interface GrantableOperation { nonDelegable?: boolean; /** Legacy wire alias; Harper's authorization entry is registered under the canonical name. */ aliasOf?: string; - /** Extra qualification shown wherever the operation is offered or listed. */ + /** The band in which it is authorized on a path that never consults the list. */ + outOfGate?: GrantBand; + /** Why the list does not govern it, shown on its chip wherever `outOfGate` applies. */ caveat?: string; - /** Authorized on its own path, so the allowlist neither grants nor restricts it. */ - outOfGate?: boolean; /** Where listing the name stops or starts matching the gate; absent = it matches on every 5.x. */ gate?: GateChange; } /** - * Gate 1 checks a listed name against the authorization entry's `api_name`. Before 5.3 that fell back - * to the handler's own name when the entry had none, which no snake_case grant can equal - * (HarperFast/harper#2175); 5.3 names every entry, deliberately naming a few `null`. + * Gate 1 checks a listed name against the authorization entry's `api_name`. Before 5.3.0-beta.3 that + * fell back to the handler's own name when the entry had none, which no snake_case grant can equal + * (HarperFast/harper#2175); beta.3 names every entry, deliberately naming a few `null`. "5.3" below + * means the `5.3` band: * - `from-5.3`: unnamed before 5.3, so listing it grants nothing there. * - `until-5.3`: registered under its wire name, so the fallback matched it; named `null` from 5.3. * - `never`: no version lets a listing reach it, so it is not offered. */ export type GateChange = 'from-5.3' | 'until-5.3' | 'never'; -/** Which side of that change an instance is on; resolve it once per version, not per operation. */ -export type GrantBand = 'pre-5.3' | '5.3'; +/** + * Which allowlist rules an instance follows; resolve it once per version, not per operation. Harper + * changed gate 1 twice during 5.3, so the bands are named for those changes, not for releases: + * - `pre-5.3`: the gate runs after the super_user and structure_user returns, and the SQL path never + * calls it. + * - `5.3-early` (5.3.0-alpha.1 to beta.2): it runs ahead of both and on the SQL path, but the + * entries `from-5.3` marks are still unnamed. + * - `5.3` (5.3.0-beta.3 on): every entry names its grant as well. + */ +export type GrantBand = 'pre-5.3' | '5.3-early' | '5.3'; + +/** The first tagged build that runs gate 1 first and on `sql` (harper f8a5aa90a). */ +const ALLOWLIST_GATE_RUNS_FIRST_FROM = '5.3.0-alpha.1'; /** The first tagged build that names every authorization entry, not 5.3.0's earliest prerelease. */ export const GATE_NAMES_EVERY_ENTRY_FROM = '5.3.0-beta.3'; export function resolveGrantBand(version: string): GrantBand { - return wasAReleasedBeforeB(GATE_NAMES_EVERY_ENTRY_FROM, version) ? '5.3' : 'pre-5.3'; + if (wasAReleasedBeforeB(GATE_NAMES_EVERY_ENTRY_FROM, version)) { + return '5.3'; + } + return wasAReleasedBeforeB(ALLOWLIST_GATE_RUNS_FIRST_FROM, version) ? '5.3-early' : 'pre-5.3'; +} + +/** Whether a structure user's DDL and `sql` have to be listed too, rather than skipping the list. */ +export function allowlistGatesEveryPath(band: GrantBand): boolean { + return band !== 'pre-5.3'; +} + +export function isOutOfGate(name: string, band: GrantBand): boolean { + return catalogByName.get(name)?.outOfGate === band; } export type GateVerdict = 'grants' | 'needs-5.3' | 'never'; @@ -81,13 +106,13 @@ export function gateVerdict(name: string, band: GrantBand): GateVerdict { /** * Whether listing this name changes nothing: an alias the gate resolves elsewhere, an operation - * whose handler self-enforces super_user, one the gate cannot match, or `sql`, which is authorized - * on its own path and so is neither granted nor restricted by the list. The chip and the effective - * count must agree on this, or the same component credits access it also calls inert. + * whose handler self-enforces super_user, one the gate cannot match, or one this band authorizes + * on its own path. The chip and the effective count must agree on this, or the same component + * credits access it also calls inert. */ export function isInertGrant(name: string, band: GrantBand): boolean { const info = catalogByName.get(name); - return !!info?.aliasOf || !!info?.nonDelegable || !!info?.outOfGate || gateVerdict(name, band) !== 'grants'; + return !!info?.aliasOf || !!info?.nonDelegable || isOutOfGate(name, band) || gateVerdict(name, band) !== 'grants'; } export interface OperationGroup { @@ -155,11 +180,11 @@ export const OPERATION_CATALOG: readonly GrantableOperation[] = [ name: 'sql', su: false, category: DATA, - // serverUtilities routes `sql` to checkASTPermissions/verifyPermsAST, which never consults - // the allowlist — so this entry neither grants nor restricts SQL (HarperFast/harper#2175). - outOfGate: true, - caveat: 'SQL is authorized against table permissions, not this list — listing or omitting it ' - + 'does not restrict SQL statements.', + // Before 5.3 serverUtilities sends `sql` straight to checkASTPermissions, which never consults the + // allowlist. From 5.3.0-alpha.1 it runs gate 1 on `sql` first, so it is a grant like any other. + outOfGate: 'pre-5.3', + caveat: "This instance's Harper authorizes SQL against table permissions only, so listing or omitting " + + 'sql here changes nothing. Harper 5.3 and later gate it by this list as well.', }, { name: 'insert', su: false, category: DATA }, { name: 'update', su: false, category: DATA }, diff --git a/src/features/instance/config/roles/preparePermissionForSave.ts b/src/features/instance/config/roles/preparePermissionForSave.ts index 8b76f40a8..b588432ab 100644 --- a/src/features/instance/config/roles/preparePermissionForSave.ts +++ b/src/features/instance/config/roles/preparePermissionForSave.ts @@ -9,7 +9,7 @@ import { RESERVED_PERMISSION_KEYS, rolePreventsOperationsAllowlist } from '@/int * `operations` survives only where Harper accepts it: `validateNoSUPerms` rejects any multi-key * permission setting `super_user: true` or `cluster_user: true`, so keeping the allowlist there * would fail the save outright — it is dropped alongside the table permissions instead. A - * `structure_user` role may carry one (the gate still runs for everything but DDL), and it + * `structure_user` role may carry one (Harper gates it, and from 5.3.0-alpha.1 its DDL too), and it * survives only as an actual array: any other shape under that key is not an allowlist. * * `structure_user` may also be an array of database names, which grants DDL on *those* databases diff --git a/src/hooks/checkOperationPermission.test.ts b/src/hooks/checkOperationPermission.test.ts index 9c79e3df3..f8da8369a 100644 --- a/src/hooks/checkOperationPermission.test.ts +++ b/src/hooks/checkOperationPermission.test.ts @@ -106,8 +106,8 @@ describe('operation mappings', () => { }); describe('checkTableActionAllowed', () => { - // verifyPerms clears a super_user before reaching the allowlist, so gating one would hide UI that - // works. + // Before 5.3 verifyPerms clears a super_user ahead of the allowlist, and from 5.3 Harper refuses to + // store a list on one, so gating one would hide UI that works. it('ignores the allowlist for a super_user', () => { expect(checkTableActionAllowed(perm({ super_user: true, operations: [] }), 'insert')).toBe(true); expect(checkTableActionAllowed(perm({ super_user: true, operations: ['read_only'] }), 'delete')).toBe( @@ -115,8 +115,7 @@ describe('checkTableActionAllowed', () => { ); }); - // isElevatedRole counts these two as well, because it answers a broader question. DML is where - // they are still gated. + // Elevated, but not past the list for DML on any version. it('still applies the allowlist to structure_user and cluster_user', () => { expect(checkTableActionAllowed(perm({ structure_user: true, operations: ['read_only'] }), 'insert')) .toBe(false); diff --git a/src/hooks/checkOperationPermission.ts b/src/hooks/checkOperationPermission.ts index 131fca745..b87b4e51e 100644 --- a/src/hooks/checkOperationPermission.ts +++ b/src/hooks/checkOperationPermission.ts @@ -11,9 +11,10 @@ import type { ImportSource } from '@/integrations/api/instance/database/importDa import { getOperationsAllowlist } from '@/integrations/api/localRolePermission'; /** - * Harper's `operations` allowlist (5.0+) is reached only by roles verifyPerms has not already cleared: - * `isSuperUser && !isSuSystemOperation` returns first, and `structure_user` returns for create/drop - * database, table, and attribute. Table reads and writes are the only Studio surface it can deny. + * Harper's `operations` allowlist (5.0+) never applies to a super_user: before 5.3 verifyPerms clears + * one first, and from 5.3.0-alpha.1, where the list runs ahead of every privilege return, + * validateNoSUPerms refuses to store a list on one. Table reads and writes are the only Studio + * surface these checks gate. */ export const TABLE_ACTION_OPERATIONS: Record