Skip to content

feat(roles): surface operation-level grants (permission.operations) in the roles/users UI - #1628

Merged
dawsontoth merged 22 commits into
stagefrom
role-operations-allowlist
Aug 18, 2026
Merged

feat(roles): surface operation-level grants (permission.operations) in the roles/users UI#1628
dawsontoth merged 22 commits into
stagefrom
role-operations-allowlist

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Closes #1627.

What this adds

Harper 5.0+ roles accept a permission.operations allowlist (deny-unlisted, with explicit listing acting as a scoped super_user delegation). Studio's role editor now surfaces it:

  • Role editor (EditRoleModal): a "Restrict operations" section above the JSON editor — predefined group toggles (read_only / admin_read / standard_user / agent), a categorized operation picker with super_user badges on delegating grants, chips for the selected list, and an effective-operations summary (groups expanded). The section is a lens over the JSON text — the JSON stays the single source of truth, and structured writes float operations to the top of the document (right after the super_user-family flags) so it's visible without scrolling.
  • Free-text escape hatch for component-registered operations (5.2+, server.registerOperation): an exact-miss filter that looks like an operation name can be granted as a custom entry, case preserved.
  • Roles table: an "Operations" column — the effective count, "—" when unrestricted, or a flag when the value is malformed or the role is elevated (see below).
  • User add/edit forms: a one-line note when the selected role carries an allowlist.
  • Version gating: the section only renders on Harper ≥ 5.0.0-alpha.1; per-operation addedIn floors (5.1 deployments/agent ops, 5.2 secrets/env/backups) filter the picker; the agent group needs ≥ 5.2.0-alpha.1.

The catalog (operationsCatalog.ts) is static, extracted from Harper source at tags v5.0.0 / v5.1.0 / v5.2.2, because core has no discovery operation for grantable names (the issue's open question). Unknown names in existing role JSON are preserved and rendered, never dropped. Non-delegable ops (secrets store, managed backups, get_deployment_payload — handlers self-enforce super_user) are never offered.

Where to look

  • src/integrations/api/localRolePermission.ts — reserved-key model. Key identity (not value shape) decides what's a database record vs a reserved key, which is what protects a database literally named operations from clobbering (or being clobbered by) the allowlist in defaultCalculator/preparePermissionForSave.
  • EditRoleModal.tsx — the JSON↔picker sync. Monaco applies programmatic value updates without firing onChange (see AGENTS.md), which is what makes the lens loop-free. The save-path strip loop round-trips an array-shaped operations for unconditionally-elevated roles rather than deleting what the author typed, while a non-array value is still stripped like any table permission.
  • Elevated roles are the subtle part. validateNoSUPerms rejects any multi-key permission that sets super_user: true or cluster_user: true, so an allowlist cannot coexist with those flags — it is dropped at save time and the conflict is stated in the editor, the roles table, and the user-form summary. structure_user is different: only DDL short-circuits the gate, so the allowlist is enforced there and the UI names the carve-out rather than calling the list inert.
  • A pre-5.0 role can carry a fatal operations value. A v4 role granting a database named operations survives the upgrade as operations: {tables: …}, and from v5.0.0-alpha.8 that throws in expandOperationsPerms during the user-cache load — failing authentication for every user on the instance, triggered by assigning the role. The editor and the user-assignment form both say so and ask for the key to be removed. Filed against core as Non-array permission.operations throws during user-cache load, breaking authentication instance-wide (reachable via v4→v5 upgrade) harper#2194.
  • Grants the server can't act on yet are labelled, not hidden. The 22 operations whose authorization entry omits api_name (role operations allowlist: grants are gate-inert for ops registered without api_name (deploy_component, get_status, …); sql bypasses the allowlist harper#2175) badge as "not yet enforced" instead of promising a delegation; sql says it is authorized against table permissions rather than this list. Both stay offered so roles are ready when core lands the fix — say the word if you'd rather withhold them.
  • preparePermissionForSave.ts — extracted from the modal so the strip behavior is unit-tested.
  • The LocalRolePermission index signature widened to admit operations; the three consumers that indexed it by database name now route through getDatabasePermissionRecord (with null/array guards that also fix a couple of latent crash shapes: {"data": {}}, attribute_permissions: null with no legacy key).

Deliberate tradeoffs / open items for review

  • Permission hooks don't consult the allowlist yet (useInstanceManagePermission etc. still short-circuit on super_user), so a user holding a restricted-SU role sees admin UI that will 403 server-side. Deliberately out of scope here — it needs a UI-surface→operation mapping; flagged as a follow-up.
  • Effective-operation counts are advisory: group expansion is a client-side copy of Harper's OPERATION_PERMISSION_GROUPS as of 5.2.2, and legacy wire aliases (describe_database/describe_schema etc.) count as distinct names, matching allowlist semantics rather than distinct handlers. If core grows a list_grantable_operations op, the static catalog should defer to it.
  • Per-keystroke JSON parse: the lens re-parses the permission document on each editor keystroke to keep the picker in sync. Fine for typical documents; a debounce is the fix if very large (hundreds of tables, attributes on) roles ever feel janky. The picker's array identity is stabilized so unrelated typing doesn't re-render the picker subtree.
  • Harper-side observation from the source dive (not blocking this UI): several built-in SU ops are registered in requiredPermissions without an api_name (e.g. deployComponent, getStatus, getBackup), and the gate resolves requiredPermissions.get(op)?.api_name ?? op against the handler's camelCase name — which can never match a snake_case grant. Unless I've misread the dispatch, granting deploy_component/get_status (the issue's own example) may be gate-inert in core today. Now filed as role operations allowlist: grants are gate-inert for ops registered without api_name (deploy_component, get_status, …); sql bypasses the allowlist harper#2175 (which also covers sql bypassing the allowlist gate entirely); the UI offers everything validateOperations accepts, so it's correct either way once core is.

Docs

Companion PR: HarperFast/documentation#631 — the reference page's two-gate description reads as universal, which is what led me to expect a "restricted super user". It now states the three limits (SU/CU rejected at write time, the structure_user DDL carve-out, sql authorized on its own path). Studio's own docs are a single overview page with no per-feature UI reference, so nothing there needs updating.

Review coverage

Authored by an LLM (Claude Fable 5). Cross-model coverage, all via the bundled pre-push CLI:

  • Round 1 — Codex, full diff. 8 findings; 6 fixed, 2 refuted with source evidence.
  • Round 2 — Gemini, delta (Codex hit OpenAI capacity errors). 3 minor items fixed.
  • Round 3 — Codex + Gemini, full diff at head. 8 findings, all acted on: a save path that
    silently widened a structure_user role, user forms that warned about an instance-wide auth
    outage and then permitted it, an empty-allowlist claim that contradicted our own sql caveat, an
    effective count that credited grants the chips call inert, plus a per-keystroke console.error
    flood and a React Query observer per table row.
  • Human review — @cb1kenobi and @kriszyp, 48 inline findings over nine rounds, all resolved.
    The three High ones each corrected a claim about Harper I had inferred rather than traced; the
    most consequential established that a non-iterable operations value fails the user-cache load
    and takes authentication down instance-wide (Non-array permission.operations throws during user-cache load, breaking authentication instance-wide (reachable via v4→v5 upgrade) harper#2194).
  • Caveats: the Harper-domain adjudicator leg failed on rounds 1–2 (findings adjudicated inline);
    Cursor is unavailable on this machine.

🤖 Generated with Claude Code

Human-Review-Need: 4 @ bcfb362

…n the roles/users UI

A role's permission.operations allowlist (Harper 5.0+) gets a structured editor in the
role modal: group toggles (read_only/admin_read/standard_user/agent), a categorized
operation picker with super_user-delegation badges and a custom-name escape hatch for
component-registered operations, plus an effective-operations summary. The roles table
gains an Operations column, and user forms note when the chosen role is restricted.
The static catalog is extracted from the Harper source per version (5.0/5.1/5.2) since
core has no discovery operation; unknown names are preserved, non-delegable ones
(secrets store, managed backups, get_deployment_payload) are never offered.

Fixes #1627

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth requested review from BboyAkers and heskew August 14, 2026 19:59
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 56.51% 6968 / 12329
🔵 Statements 57.08% 7493 / 13126
🔵 Functions 48.82% 1739 / 3562
🔵 Branches 51.17% 5004 / 9779
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/instance/config/roles/defaultCalculator.ts 75.75% 28.2% 55.55% 74.19% 65-69, 88-122, 142-161
src/features/instance/config/roles/index.tsx 0% 0% 0% 0% 19-100
src/features/instance/config/roles/preparePermissionForSave.ts 100% 100% 100% 100%
src/features/instance/config/roles/constants/tableDefinition.tsx 8.69% 0% 0% 8.69% 30-125
src/features/instance/config/roles/modals/EditRoleModal.tsx 81.96% 88.88% 66.66% 81.96% 37, 88, 165, 189-191, 201-210, 228
src/features/instance/config/roles/operations/AllowlistSupportedContext.tsx 50% 100% 0% 50% 12
src/features/instance/config/roles/operations/OperationsAllowlistEditor.tsx 88.5% 80.76% 90.9% 88.75% 281, 319-330
src/features/instance/config/roles/operations/OperationsValueNotice.tsx 100% 100% 100% 100%
src/features/instance/config/roles/operations/RoleOperationsSummary.tsx 100% 88.88% 100% 100%
src/features/instance/config/roles/operations/operationsCatalog.ts 100% 95.65% 100% 100%
src/features/instance/config/roles/operations/useOperationsAllowlistSupported.ts 100% 100% 100% 100%
src/features/instance/config/roles/operations/useRoleBlocksAssignment.ts 0% 0% 0% 0% 15-19
src/features/instance/config/users/components/AlterUserForm.tsx 0% 0% 0% 0% 43-185
src/features/instance/config/users/modals/AddUserModal.tsx 0% 0% 0% 0% 44-187
src/hooks/checkSchemaTablePermission.ts 100% 100% 100% 100%
src/hooks/usePermissions.ts 35.22% 37.6% 47.05% 35.63% 45-51, 72-93, 112, 115, 117, 128-138, 159-166, 178-244
src/integrations/api/localRolePermission.ts 100% 100% 100% 100%
Generated in workflow #1752 for commit 6da21ff by the Vitest Coverage Report Action

@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 introduces a structured operations allowlist editor for managing role-level API restrictions in Harper 5.0+, along with associated catalog utilities, tests, and user assignment summaries. Key feedback includes refactoring a ref mutation out of useMemo in EditRoleModal, avoiding parameter mutation in preparePermissionForSave, using a native checkbox input instead of the custom text input in OperationsAllowlistEditor, correcting Tailwind CSS v4 syntax for dynamic variables, and ensuring the Badge component supports the warning variant.

Comment thread src/features/instance/config/roles/modals/EditRoleModal.tsx Outdated
Comment thread src/features/instance/config/roles/preparePermissionForSave.ts
Comment thread src/features/instance/config/roles/operations/OperationsAllowlistEditor.tsx Outdated
@dawsontoth
dawsontoth marked this pull request as ready for review August 14, 2026 20:02
@dawsontoth
dawsontoth requested a review from a team as a code owner August 14, 2026 20:02
Stabilize the picker's array identity with a serialization-keyed useMemo
instead of a ref written during render, and make preparePermissionForSave
return a copy instead of mutating its argument.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dawsontoth and others added 3 commits August 14, 2026 17:01
- Treat a record-shaped operations key as a real database on pre-5.0
  instances (v4 reserved no such field), by shape in the narrowing helper
  and by version in the default calculator.
- Skip the per-keystroke permission parse entirely when the instance
  doesn't support allowlists.
- Widen the custom-grant pattern: component operation names are exact
  strings (deploy-v2, acme.deploy), not identifiers.
- Correct the non-delegable chip: on a restricted super_user role the
  entry is load-bearing, not a no-op.
- Cover the full lens wiring with an EditRoleModal test
  (picker -> JSON ordering -> preparePermissionForSave -> alter_role).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…save parse

From the Gemini delta review: getDatabasePermissionRecord ran once per table
instead of once per database, the save path parsed without guarding against
Monaco's stale validity markers, and RoleOperationsSummary had no tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… roles

Harper's verifyPerms returns early for super users ('admins can do (almost)
anything') and for structure users on DDL, both before the operations gate is
reached — so an allowlist on such a role is stored and validated but never
enforced. The editor, the roles table, and the user-form summary all claimed
the opposite, which is the worst failure mode a permissions UI has: an admin
believing they revoked access they still hold.

Also from the deep review:
- Offer only the canonical spelling of an alias pair; both spellings dispatch
  to one handler whose authorization entry carries the canonical api_name, so
  granting the alias was inert. Alias chips now say so.
- Surface a malformed operations value instead of rendering it as no
  restriction; Harper treats any present value as active.
- Leave database-scoped structure_user roles (structure_user: ['db']) alone at
  save time — they still rely on their explicit table permissions.
- Skip a database named __proto__ in the template and require own-property
  lookups, so it can't hit the prototype setter and silently disappear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/features/instance/config/roles/preparePermissionForSave.ts Outdated
Comment thread src/integrations/api/localRolePermission.ts Outdated
Comment thread src/features/instance/config/roles/operations/operationsCatalog.ts
Comment thread src/features/instance/config/roles/operations/operationsCatalog.ts
Comment thread src/features/instance/config/roles/modals/EditRoleModal.tsx Outdated
Comment thread src/features/instance/config/roles/constants/tableDefinition.tsx Outdated
…tructure_user, inert grants

Chris's review caught that my previous 'elevated roles' handling was wrong in
both directions, verified against harper v5.2.2:

- super_user/cluster_user: validateNoSUPerms rejects any multi-key permission
  setting those flags, so keeping the allowlist made alter_role 400 outright.
  It is dropped again at save time and the conflict is stated in the editor,
  the roles table, and the user-form summary before the author saves.
- structure_user: only DDL short-circuits the gate (create/drop table and
  attribute, plus create/drop database when true) — the allowlist IS enforced
  for everything else, so the UI now names the carve-out instead of calling the
  whole list inert and inviting its deletion. cluster_user never reaches the
  gate code at all.
- The 22 operations whose authorization entry omits api_name are badged
  'not yet enforced' rather than promising a super_user delegation they cannot
  receive (harper#2175); they stay offered so roles are ready when that lands.
- sql is authorized against table permissions rather than the allowlist, so the
  editor copy and the catalog entry say so.
- The custom-grant fallback no longer re-offers alias spellings or case
  variants of known names, both of which the server rejects or ignores.
- expandEffectiveOperations folds alias spellings into their canonical name, so
  the count agrees with the chips beside it (read_only is 13 handlers, not 15).
- A transient JSON error no longer makes the section claim the role is
  unrestricted; it describes the last good parse.
- The operation list gets an sr-only path, and the unrestricted cell an
  aria-label, so title is not the only affordance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to be awesome!
Anyway, some issues to address, mostly minor.
🤖 Reviewed with Codex

Comment thread src/integrations/api/localRolePermission.ts Outdated
Comment thread src/features/instance/config/roles/operations/operationsCatalog.ts Outdated
Comment thread src/features/instance/config/roles/operations/RoleOperationsSummary.tsx Outdated
Comment thread src/features/instance/config/roles/defaultCalculator.ts Outdated
Comment thread src/integrations/api/localRolePermission.ts Outdated
Comment thread src/features/instance/config/roles/operations/operationsCatalog.ts
Comment thread src/features/instance/config/roles/operations/OperationsAllowlistEditor.tsx Outdated
Comment thread src/features/instance/config/roles/constants/tableDefinition.tsx Outdated
- Raise the version floor to v5.0.0-alpha.8, the first tagged build with both
  the authorization gate and the role validation; earlier 5.0 alphas would take
  the key and fail the save.
- Add registration_info to the gate-inert set: its entry omits api_name like
  the other 22, and a role carrying any allowlist is denied it even when listed.
- Model the operations key by shape everywhere: a pre-5.0 role granting a
  database named 'operations' is no longer reported as a malformed allowlist in
  the roles table or the user-form summary.
- Keep a database named __proto__ instead of dropping it — define the property
  rather than assigning through the prototype setter.
- Distinguish the structure_user forms: the array shape reaches DDL only on its
  listed databases and never create/drop database, so the copy says which.
- Stop promising a super_user delegation for gate-inert grants, and exempt the
  structure_user DDL carve-out from the cannot-run-anything warning.
- Use sr-only text for the unrestricted cell; aria-label is not exposed on a
  roleless span.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the role-operations-allowlist branch from 79d89b9 to dc2c6a5 Compare August 17, 2026 22:05
Comment thread src/integrations/api/localRolePermission.ts Outdated
Comment thread src/features/instance/config/roles/operations/OperationsAllowlistEditor.tsx Outdated
Comment thread src/features/instance/config/roles/operations/OperationsAllowlistEditor.tsx Outdated
Comment thread src/features/instance/config/roles/defaultCalculator.ts
Comment thread src/features/instance/config/roles/operations/RoleOperationsSummary.tsx Outdated
dawsontoth and others added 2 commits August 17, 2026 18:23
…_proto__ path

Chris caught a regression I introduced last round: classifying a record-shaped
operations value as a pre-5.0 database was version-blind, and on a 5.x instance
role_validation rejects a non-array outright (OPERATIONS_MUST_BE_ARRAY). The
editor therefore rendered "No operation-level restriction" over real table
permissions, and one click on the switch would have overwritten them.

- classifyOperationsValue/hasMalformedOperations now require the instance's
  allowlist support, so a record reads as a broken allowlist at or above the
  floor (left untouched) and as a database below it.
- The roles table and the user-form summary read that support through a shared
  hook rather than assuming, so neither can go version-blind again.
- The array-shaped structure_user copy no longer claims the DDL ops stay gated:
  that branch is terminal, so listing them cannot reach another database. The
  boolean form's create/drop database reach is stated in the summary too.
- Restore the space the array branch lost, which rendered "drop_attributeon dev".
- Add defaultCalculator tests: a database named __proto__ survives as an own,
  serializable key through the editor's round trip, plus the reserved-key
  behavior on both sides of the floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oles

The summary appended the DDL carve-out after an absolute 'cannot run any
operation.', so the two sentences contradicted each other. The note now
qualifies the clause instead of following it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/integrations/api/localRolePermission.ts Outdated
Comment thread src/features/instance/config/roles/operations/useOperationsAllowlistSupported.ts Outdated
…he version loads

- getDatabasePermissionRecord now takes the same allowlistSupported flag as
  classifyOperationsValue, which delegates to it instead of repeating a shape
  test. The version alone decides whether the operations key is the allowlist
  or an ordinary database, so the two can no longer disagree: a record-shaped
  value is a table grant below the floor and a broken allowlist above it, and
  a record without a tables key is judged the same way as any other database.
  A test asserts the two agree across both worlds for four value shapes.
- useOperationsAllowlistSupported returns boolean | undefined so "still
  loading" is no longer indistinguishable from "unsupported". The roles table
  and the user-form summary render nothing until it resolves rather than
  briefly calling a restricted role unrestricted; the permission hooks resolve
  it fail-closed, withholding a grant on a database named operations rather
  than inventing one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the role-operations-allowlist branch from d8dd7ad to 61d068b Compare August 17, 2026 22:43
Comment thread src/hooks/checkSchemaTablePermission.ts Outdated
Comment thread src/hooks/usePermissions.ts Outdated
…pending verdict

Harper's permissionsTranslator hands a role the translated table permissions
under an `operations` key whenever a database of that name exists, so an
upgraded v4 role still holds a real grant there. Threading the version into
checkSchemaTablePermission made the browse UI stricter than the server for
exactly that role, so those two call sites go back to reading the key by shape
— which also drops the hook call that ignored usePermissions' entityId.

The parameter is renamed to operationsIsAllowlist, since the right answer
depends on the question being asked rather than on the version alone, and both
readings are documented next to it. Adds tests for the upgraded-role grant, for
a real allowlist never reading as one, and for the three verdicts the summary
gives while the version is loading, below the floor, and above it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/integrations/api/localRolePermission.ts
…hook's tri-state

The malformed branch told anyone holding an upgraded v4 role that granted a
database named `operations` to fix the value in the role editor. Harper still
honors those table grants (permissionsTranslator's schema loop), and replacing
the record with an array makes perms.operations.tables[t] throw — failing
permission translation for every request that user makes. So the advice
destroyed a live grant and could lock the user out.

- classifyOperationsValue gains `database-collision` for a record under the
  reserved key on a supporting instance. The summary describes it and points at
  renaming the database; the roles table marks it "database grant"; the editor
  refuses to touch it via isUneditableOperationsValue (renamed from
  hasMalformedOperations, which no longer covers every hands-off case).
- Rewrite the module header to state the two questions the `operations` key
  answers and why the access checks deliberately answer differently, so the
  next reader doesn't re-unify them for a third time.
- Add a test for useOperationsAllowlistSupported itself: pending resolves to
  undefined rather than false, then false below the floor and true above it.
  Collapsing the tri-state now fails a test instead of passing the suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/features/instance/config/roles/modals/EditRoleModal.tsx Outdated
Comment thread src/integrations/api/localRolePermission.ts Outdated
Comment thread src/integrations/api/localRolePermission.test.ts Outdated
Comment thread src/features/instance/config/roles/operations/RoleOperationsSummary.tsx Outdated
…tructive advice

The previous commit corrected the wording in the user-assignment summary but
left the same value hitting the old copy in the role editor — the surface an
admin is most likely to act from. isUneditableOperationsValue collapsed
`malformed` and `database-collision` into one flag, and both landed on "edit it
in the JSON below". Following that replaces a live, server-honored grant with an
array, which makes perms.operations.tables[t] throw and fails permission
translation for every request that user makes.

- Extract OperationsCollisionNotice and use it from both surfaces, so the two
  cannot drift again; the editor branches on the classification rather than a
  boolean that had stopped describing its contents.
- Name the remedy Harper actually supports: there is no rename-database
  operation, and the role's permission key still reads `operations` afterwards,
  so the copy asks for the grants to be moved and re-keyed in the role JSON.
- Fix the JSDoc pointer left dangling by the rename, and make the agreement
  test assert its own contract — it compared the record reading against
  `supported`, which made the above-floor half compare false to false.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/features/instance/config/roles/operations/OperationsCollisionNotice.tsx Outdated
Comment thread src/integrations/api/localRolePermission.ts
Chris traced this one further than either of us had: a role whose `operations`
is not an array never reaches permissionsTranslator at all. listUsers calls
cacheExpandedOperationsPerms for every user, its guard is truthiness-only, and
expandOperationsPerms' for…of throws on a record — rejecting the user-cache
load for the whole instance. Authentication then fails for every user, not just
the holder of that role, on exactly the version range where the allowlist
exists (the throw landed in the same v5.0.0-alpha.8).

So "those table grants still apply" was the opposite of true, and presented an
instance-wide outage as a benign coexistence problem. The notice now says what
happens, names assignment as the trigger (only roles held by a user are
expanded), and asks for the key to be removed rather than edited. Both non-array
verdicts share it, since they fail identically, which puts
isUneditableOperationsValue back in front of both surfaces instead of leaving it
as test-only API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/features/instance/config/roles/operations/OperationsCollisionNotice.tsx Outdated
Comment thread src/features/instance/config/roles/operations/OperationsCollisionNotice.tsx Outdated
Comment thread src/features/instance/config/roles/operations/OperationsCollisionNotice.tsx Outdated
Comment thread src/integrations/api/localRolePermission.ts Outdated
…lid ones

I collapsed both non-allowlist verdicts onto one notice claiming an
instance-wide auth outage, having tested only the shapes that throw. Against
harper's compiled expandOperationsPerms, only NON-ITERABLE values do: a record,
`true`, a number. A bare string iterates per character and a mixed array
iterates fine, so both expand without error and merely fail write-time
validation — and a mixed array is reachable by ordinary editing, so the fatal
wording was firing on a routine typo.

- classifyOperationsValue now returns `breaks-auth` for the non-iterables and
  keeps `malformed` for the iterable-but-invalid ones; the notice carries the
  matching severity and remedy.
- The remedy for the fatal case was also incomplete: moving the grants leaves
  the `operations` database in place, and while one exists any role carrying an
  allowlist array hits perms[s].tables[t] in permissionsTranslator and 500s. It
  now says to drop that database and states the instance-wide consequence.
- The `assigning` surfaces (a role picker in the user forms) no longer point at
  "the JSON below", which does not exist there — they say to pick another role
  and repair this one in the role editor.
- Drop the retracted "permissionsTranslator still grants those tables" narrative
  from the module docs; auth fails before any request reaches it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/features/instance/config/roles/operations/OperationsValueNotice.tsx Outdated
Comment thread src/features/instance/config/roles/operations/OperationsValueNotice.tsx Outdated
Comment thread src/integrations/api/localRolePermission.ts Outdated
…or gate

- The drop-the-database step was inside the "to keep the table grants" branch,
  but it is required on both: removing the key from one role leaves the database
  in place, and translateRolePermissions then throws for any role that carries
  an allowlist array — including the one the admin is about to create. Hoisted
  out of the conditional.
- "Harper rejects it as an allowlist" was only true at write time. The gate
  enters on operations !== undefined and enforces whatever the value expands to,
  so a bare string denies every operation rather than being inert. The copy and
  the JSDoc now say what is enforced.
- breaks-auth is floor-gated again, matching its own documentation: below the
  floor there is no expansion to throw, so a non-iterable value is merely
  invalid there rather than an instance-wide outage.
- Add OperationsValueNotice.test.tsx. Every previous assertion reached the
  component through RoleOperationsSummary, which always passes `assigning`, so
  the editor wording — the half that carries the remedy — had no coverage;
  deleting the drop-the-database clause left the suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/integrations/api/localRolePermission.ts Outdated
Comment thread src/features/instance/config/roles/operations/OperationsValueNotice.tsx Outdated
Comment thread src/integrations/api/localRolePermission.ts Outdated
dawsontoth and others added 3 commits August 18, 2026 12:09
Each of these cost a review round on #1628: the allowlist does not restrict
super users (and cannot be saved alongside that flag), structure_user is gated
except for DDL, ~23 operations plus every alias spelling are inert when granted,
sql bypasses the gate, and a non-iterable value fails the user-cache load for
the whole instance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rning

Two of these are fallout from last round's floor gate, which I bolted onto the
return without re-reading what it now covered.

- A well-formed allowlist BELOW the floor was being called malformed, so a
  pre-5.0 instance was told a role is locked down when the key is inert there —
  backwards, on the surface whose job is to warn. Sub-floor values now classify
  as `inert` (or `database` for a real record) and the surfaces stay quiet,
  since no gate, expansion or validation reads the key on those versions.
- `breaks-auth` claimed the instance-wide outage for falsy values too, but
  cacheExpandedOperationsPerms returns early on `!operations`, so `false`, `null`
  and `0` never reach the expansion that rejects listUsers. They still enter the
  gate, which tests `!== undefined`, so they throw per request instead — bad, but
  scoped to the role. The verdict now requires truthiness and the malformed copy
  covers what those values actually do.
- Restate the `malformed` JSDoc, which still described the retracted "expand
  without error, only fail write-time validation" behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From the pre-push Codex review of the current head:

- Saving a role whose `operations` value the editor refuses to touch still ran
  the normalizer, which drops a non-array value — so an unrelated edit to
  `{structure_user: true, operations: "read_only"}` silently removed a
  restriction Harper was enforcing. Save is now disabled until the value is
  repaired in the JSON.
- The user forms warned that assigning a role would break authentication
  instance-wide and then let the admin do it. Both now refuse the submit.
- "cannot run any operation" contradicted our own sql caveat: SQL is authorized
  against table permissions, so an empty allowlist does not stop it. Both
  surfaces now say "Operations API call" and name the exception.
- The effective count folded a directly granted alias to its canonical name and
  counted non-delegable entries, crediting the role with access the chips beside
  it call inert. Group members still fold; direct entries no longer do.
- The drop-the-database remedy is shown only for a record-shaped value, not for
  `true` or a number, where no such database is implied.
- Parse the draft with a quiet parser: safeParse console.errors, and a
  half-typed document is invalid on most keystrokes.
- Resolve the instance version once for the roles table instead of once per row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/features/instance/config/roles/operations/OperationsAllowlistEditor.tsx Outdated
Comment thread src/features/instance/config/roles/operations/operationsCatalog.ts
Comment thread src/features/instance/config/roles/operations/operationsCatalog.ts Outdated
…adicting the chips

- `databaseCollision` was added to the props type but never destructured, so the
  edit silently did nothing and the drop-the-database remedy stayed
  unconditional — shown even for `operations: true`, where no such database
  exists. The prop is read now, with a test for each branch. I did not assert
  the anchor on that replacement, which is why it passed review as done.
- `delete_records_before` shares delete_files_before's handler, whose entry
  carries no api_name, so it is gate-inert like its twin. Added to the set.
- `isInertGrant` is now the single definition of "listing this grants nothing",
  used by both the chip and the effective count. The count previously used two
  of the chip's three terms, so a role holding only gate-inert names was
  reported as allowing them while every chip called them inert.
- That widened the zero-effect case, so the copy no longer says "Nothing
  selected" / "allowlist is empty" under visible chips: an all-inert list says
  so instead. Both surfaces branch on the list, not the expansion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/features/instance/config/roles/operations/operationsCatalog.ts
Comment thread src/features/instance/config/roles/operations/operationsCatalog.ts
…hat survived mutation

- `catchup` is not gate-inert and never was: its entry is keyed by the bare
  string 'catchup' (operation_authorization.ts:82,374), which is also its wire
  name, so the gate's `?? op` fallback matches and the grant works. My own
  harper#2175 write-up said exactly that and the catalog contradicted it — the
  set now documents the distinction (handler-name key vs bare-string key) so the
  next addition has to answer it.
- Apply isInertGrant to expanded group members too. Latent today since no
  group lists an inert name, but it was the one seam where the "one predicate"
  claim didn't hold. delegatesSu's gate-inert term is now redundant and gone.
- Add the guards three of these findings identified as missing: the new
  `delete_records_before` entry (deleting it previously left the suite green),
  and both "every entry is inert" branches, which survived mutation to their
  constants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, a few more things that could be cleaned up.

🤖 Reviewed with Codex

Comment thread src/features/instance/config/roles/operations/operationsCatalog.ts Outdated
Comment thread src/features/instance/config/roles/operations/OperationsValueNotice.tsx Outdated
Comment thread src/integrations/api/localRolePermission.ts Outdated
…overned sql

From Kris's review:

- The recovery order could have cost someone production data. It said to drop
  the `operations` database and only then mentioned moving its grants, and it
  spoke of "table grants" where the database holds records. The role repair now
  stands alone and is stated as sufficient ("that alone restores
  authentication"), and retiring the database is a clearly separate, optional
  migration that leads with migrating and verifying the data and says plainly
  that dropping it destroys what it holds.
- `sql` is authorized on its own path, so listing it neither grants nor
  restricts anything — it is now marked out-of-gate and excluded from the
  effective count, which no longer credits the allowlist for access it does not
  control. read_only therefore reports 12.
- The malformed copy names each shape's outcome (a string denies everything, a
  mixed array enforces what it expands to, a falsy value fails the requests)
  instead of one lumped claim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth added this pull request to the merge queue Aug 18, 2026
Merged via the queue into stage with commit c9fa98b Aug 18, 2026
2 checks passed
@dawsontoth
dawsontoth deleted the role-operations-allowlist branch August 18, 2026 17:20
)
: (
<p className="text-xs text-muted-foreground" title={summarizeOperations(effective)}>
Effectively allows {pluralize(effective.length, 'operation', 'operations')}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: dropping sql from the effective count leaves nothing on the non-empty path saying SQL is still reachable

The new outOfGate: true on the sql catalog entry feeds isInertGrant, so expandEffectiveOperations strips it. Rendered, operations: ['read_only'] now reads "restricted to 12 operations: describe_all, describe_metric, describe_schema, describe_table, get_analytics, ..." with sql nowhere in the list or the title. But serverHelpers/serverUtilities.ts:230-249 routes sql to verifyPermsAST, which contains zero references to operations/_expandedOperations -- so that role still runs arbitrary SQL SELECT/INSERT/UPDATE/DELETE against whatever its table CRUD permissions allow.

The empty branch immediately above carries (SQL is gated by table permissions, not this list). This branch does not, nor does RoleOperationsSummary.tsx:66 or tableDefinition.tsx:121, where a role with operations: ['sql'] now renders 0 allowed with an empty tooltip. The premise of the change is right (listing sql grants nothing), but the count is the only place a reader saw SQL at all, and removing it converts an over-report into an under-report of the role's real reach.

This is the unapplied half of the earlier finding on line 119, which suggested a matching caveat on read_only/standard_user since both include sql.

Suggested fix: qualify the non-empty branch unconditionally -- SQL is reachable on every restricted role, not only ones that list it -- e.g. Effectively allows {N} operations{delegatesSu ? ', ...' : ''}, plus SQL, which this list neither grants nor restricts. Mirror it in RoleOperationsSummary's summary sentence and in the roles-table title.


Generated by Barber AI

: info?.nonDelegable
? 'Always requires an actual super_user role; listing it cannot delegate the operation to a '
+ 'non-super_user role.'
: info?.outOfGate

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: this outOfGate branch is a strict no-op, and the chip's inert styling is still unguarded

info?.outOfGate ? info.caveat : info?.caveat ? info.caveat : ... returns exactly what the next branch already returns, so it changes nothing today -- sql is the only outOfGate entry and it carries a caveat. For a future outOfGate entry without one it is actively wrong: it yields undefined and skips the info?.su / unknown-name fallbacks that would otherwise supply a title.

Separately, const inert = isInertGrant(name) on line 428 still survives mutation to const inert = false (71/71 tests pass), so nothing asserts the destructive chip variant -- including the new destructive styling sql picks up from this commit's isInertGrant change. Of the four behaviors that survived mutation last round, delete_records_before and both copy branches are now killed; this one is not.

Suggested fix: drop lines 439-440; info?.caveat already covers sql. If the branch is meant to stay, give it distinct copy rather than the same info.caveat, and add a chip test that pins the variant.


Generated by Barber AI

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Surface role operation-level grants (permission.operations) in the roles/users UI

3 participants