feat(access): add an operations console for access control - #365
Draft
jirhiker wants to merge 7 commits into
Draft
feat(access): add an operations console for access control#365jirhiker wants to merge 7 commits into
jirhiker wants to merge 7 commits into
Conversation
Adds an admin-only page at /access/grants for the ADR5 permission grants the Ocotillo API exposes under /access. It opens on the admin-wide audit view, narrows by principal, capability, data type, scope, and revocation state, and creates and revokes grants. Every filter is optional on the route, so the bare call is the audit view and each filter narrows it. Filters are sent only when set: an empty string is not the same question as "any", and passing one would match only grants whose field is literally empty. "Any" is therefore a real option in each dropdown rather than a cleared field. The principal field applies on Enter rather than on each keystroke — the dropdowns have no such problem and apply on change. Grant status is derived rather than stored. The API keeps dates and a revocation stamp, not a status, because what a grant means depends on the day it is read; `grantStatusOf` resolves active / scheduled / expired / revoked against a single `today` captured once per page. Revocation asks for confirmation. There is no un-revoke on the API, so restoring access means creating a new grant, and the confirmation says that. Both mutations invalidate every grant list rather than patching the cache: a write can land outside the slice on screen. The four axes of a grant (principal type, capability, scope, data type) are built from the API's lexicon at runtime, so they are data on that side. The values are pinned here because the form needs a fixed set of choices, but the response parser takes them as plain strings: a term added to the lexicon later must not make an entire grant list fail to load. Schemas are hand-written zod, like `gisArtifacts.ts`. The committed `openapi-auth.json` snapshot predates the /access routes, so `src/generated` cannot describe them yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Preview DeploymentPreview URL: https://preview-feature-grants-operations-console-auejgdbofq-uc.a.run.app |
Extends the console from one page to three tabs — Grants, Destinations, Consent — covering the rest of the ADR5 `/access` surface. They are one subject read three ways: a grant says who may see data, consent says where it may go, and a destination is the place it goes. Each tab keeps its own route so a link into one still works, and the admin gate is checked once in the shared shell rather than per tab. Destinations list, register, and expand to show what each one may currently read. That view is computed server-side from consent rows, and an empty list means default deny — either nobody has consented or the destination is retired. The API does not distinguish those, so neither does this; the retired case is called out only because the row already knows it. Consent stays thing-scoped. Unlike grants, `GET /access/consent` still requires `thing_id` — there is no consent-wide audit view — so the tab says so in its empty state rather than looking broken. Consent rows carry `destination_id` rather than a slug, so the tab resolves names through the destination list it already loads, and falls back to the raw id when a destination is missing. A blank consenting contact is sent as null rather than as a missing field: the API allows null because the Bureau owning the well is an institutional decision, and inventing a consenting contact would be a lie. The table says "Bureau-owned" instead of leaving a gap. Grants and consent share a date-window lifecycle — both carry `starts_at`, a nullable `ends_at`, and a nullable `revoked_at`, and neither stores a status — so that derivation moves to `accessLifecycle.ts` and both tabs read from it. This keeps the two from drifting on what "expired" means. Withdrawing consent asks for confirmation and says what withdrawal does not do: copies already harvested are not recalled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Preview DeploymentPreview URL: https://preview-feature-grants-operations-console-auejgdbofq-uc.a.run.app |
Wires the UI half of ADR5 UI-surface grants. The access control provider now consults grants for a screen the role policy denied, so an admin can open one page for one person without inventing a role. Widen-only, in that order for a reason. The role policy is asked first and its yes is returned immediately: grants can only raise the floor, never lower it. That means revoking a grant returns someone to exactly what their role gives them, and an access-control outage cannot lock anyone out — a failed lookup denies, which leaves the role's answer standing. Two things a surface grant deliberately cannot do. It cannot confer writing: the grant is `read`, so only `list` and `show` consult it, and letting it widen create/edit/delete would turn "can see this nav item" into edit rights. And it cannot reveal a WIP surface, which is hidden because it is unfinished rather than because of who is asking. `can()` runs for every nav item on every render, so answers are cached per surface for the session and concurrent callers share one request. Only surfaces the role already denied are ever asked about, which bounds this to one request per denied screen. The cache is cleared on logout: it holds answers about one caller, and the next person to sign in on this tab must not inherit them. Consumes `GET /access/decision?capability=read&ui_surface=...`, which is viewer-level and answers about the caller themselves — unlike `/access/grant`, which is admin-only and could not answer this question for a non-admin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Preview DeploymentPreview URL: https://preview-feature-grants-operations-console-auejgdbofq-uc.a.run.app |
… on create The API has accepted `ui_surface` grants since the ui_surface column landed, but the console could not write one: the dialog only ever sent a data type. An admin who wanted to open a screen for someone had to POST /access/grant by hand. The dialog now asks what the grant covers — a data type or a screen — and shows the matching field. Choosing a screen forces the scope to global, which is the only scope the API accepts for one, and says why on the field. Exactly one subject reaches the API, since sending both is a 422: toCreateGrantInput sends `data_type: null` for a surface grant and `ui_surface: null` otherwise. scopeTypeFor() holds the global rule so the form and the payload cannot disagree about it. zPermissionGrant had data_type as a required string, so a surface row — which carries data_type null — would have failed to parse and taken the whole grant list down with it. It is nullable now, with ui_surface alongside, and the table's Data type column becomes Covers: the data type, or the screen. Creating a grant no longer rewrites the filters. It used to narrow the view to the new grant's principal, which dropped every other row on screen and read as "the table only shows the record I just added". The mutation already invalidates every grant list, so the refetch was never what the narrowing was for. A grant written outside the current filters now says so, with a button to narrow to it — the old behaviour, as a choice rather than a surprise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Preview DeploymentPreview URL: https://preview-feature-grants-operations-console-auejgdbofq-uc.a.run.app |
A pass over the access console driven by using it rather than building it. The grants table moves onto the shadcn table primitive with TanStack for the row model and pagination. `ui/table.tsx` is copied verbatim from refactor/wells-contacts-shadcn-table and `@tanstack/react-table` is pinned to the version that branch already chose, so the two agree when PR #347 lands and this page can move onto the shared DataTable then. Cells are shadcn Badge, Tooltip and Button; the rest of the page is still MUI. The table now reads as an operator would ask about it: * Rows are one line tall. A long reason used to set the height of its row; address and reason truncate, and the tooltip carries the full text. * A screen grant and a data grant no longer look alike — each says which it is, and a Covers filter narrows to one kind. That filter is applied to the fetched rows rather than the query, because the API filters on an exact ui_surface and not on whether a grant names one at all. * A group scope reads as its name, falling back to the id when the name has not loaded, and the grant dialog picks a group by name instead of asking for an id nobody knows. * Scoped grants are tinted, since a grant over one group or one thing is a different animal from a portal-wide one. * The table pages, and the console runs the full window width. On the consent tab, a thing is chosen by PointID through a server-side search. An id still works — pasting one from a ticket has to keep working — but typed text that is not digits and was not chosen from the list no longer submits as though it were an id. The destinations tab loses its published-data expansion. Two fixes found on the way: the revoke confirmation interpolated `data_type` directly and read "on null" for a screen grant, and the tooltip triggers were mouse-only, so their content was unreachable by keyboard. Radix positions its popper with a ResizeObserver that jsdom does not implement, so the test setup stubs one, guarded like the createObjectURL stub beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Preview DeploymentPreview URL: https://preview-feature-grants-operations-console-auejgdbofq-uc.a.run.app |
Two fixes against what the API actually does today.
The capability lexicon has five terms, not four. `view` is the screen verb —
`domain/access.py` rejects a ui_surface grant carrying anything else, and
rejects `view` over a data type — so the console could not write a valid
screen grant at all, and the decision call asked with `read`, which matches no
surface grant the API will store. A screen grant was therefore unwritable, and
would not have opened a nav item had it been written. The capability select now
offers `view` alone for a screen grant and the four data verbs otherwise,
`capabilityFor()` forces it the way `scopeTypeFor()` forces global scope, and
the decision call asks with `view`.
`GET /access/grant` now answers with a page — `{items, total, page, size}` —
rather than a bare list, so the console failed to load with a zod error. It
parses the envelope and reads `items`.
It keeps asking for one large page rather than paging against the route.
Sorting is by lifecycle and the screen/data filter is client-side, because the
route filters on an exact ui_surface rather than on whether a grant names one;
both run over the whole result, so fetching twenty-five rows would sort and
filter a slice and present it as the answer. The request asks for 500 of a
possible 10000 and the page says so when `total` exceeds what came back, rather
than showing a table that looks complete. Paging properly wants a `subject`
filter and lifecycle ordering on the route first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Preview DeploymentPreview URL: https://preview-feature-grants-operations-console-auejgdbofq-uc.a.run.app |
The screen/data filter was applied to the rows after they arrived, because the route could filter on an exact ui_surface but not on whether a grant named one at all. It takes a `subject` filter now, so the question goes with the query and the answer is narrowed before it is paged. The rows are still filtered on arrival. An API without that filter ignores a query parameter it does not recognise rather than refusing it, so a console pointed at one would show every grant as though the filter had been applied. One pass over at most a page of rows is cheaper than that being wrong, and it costs nothing once the route understands the parameter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Preview DeploymentPreview URL: https://preview-feature-grants-operations-console-auejgdbofq-uc.a.run.app |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
An admin-only console at
/access/*covering the ADR5/accesssurface, in three tabs:/access/grants/access/destinations/access/consentThey are one subject read three ways — a grant says who may see data, consent says where it may go, a destination is the place it goes — so they share a page. Each tab keeps its own route so a link into one still works, and the admin gate is checked once in the shared shell.
Draft because the
/accessroutes are not in the deployed spec yet — see Depends on.Depends on
The API side. These are on the OcotilloAPI working tree, not yet released:
This UI should not merge ahead of that API landing on staging.
Design notes
Grants are audit-first, consent is thing-scoped.
GET /access/granttakes every filter as optional, so the bare call is the admin-wide view.GET /access/consentstill requiresthing_id— there is no consent-wide view — so that tab asks for one in its empty state rather than looking broken. The asymmetry is the API's, and the UI reflects it instead of hiding it.Filters are sent only when set. An empty string is not the same question as "any" — it would match only rows whose field is literally empty — so each dropdown carries an explicit Any option that omits the parameter.
Status is derived, never stored. Grants and consent both carry
starts_at, a nullableends_at, and a nullablerevoked_at, and neither stores a status: what a row means depends on the day it is read. That derivation lives once inaccessLifecycle.tsso the two tabs cannot drift on what "expired" means.A blank consenting contact is sent as null, not omitted. The API allows null because the Bureau owning the well is an institutional decision, and inventing a consenting contact would be a lie. The table says "Bureau-owned" rather than leaving a gap.
An empty published-things list means default deny — either nobody consented or the destination is retired. The API does not distinguish those, so neither does this.
Revocation and withdrawal both confirm, and both say what they cannot undo: there is no un-revoke for a grant, and withdrawing consent does not recall copies already harvested.
Lexicon enums parse as plain strings. The grant axes and destination kinds are built from the API's lexicon at runtime. Values are pinned here only to populate the forms; a term added to the lexicon later must not make a whole list fail to load.
Hand-written zod, deliberately
accessGrants.ts,accessDestinations.ts, andaccessConsent.tshand-write their schemas, the same waygisArtifacts.tsdoes. The committedopenapi-auth.jsonsnapshot predates the/accessroutes, sosrc/generatedcannot describe them. Replace with generated schemas once/accessis in the deployed spec.Verification
Exercised against a local API actually serving
/access:All three tabs render, the grants filters round-trip, and the consent tab correctly disables Record consent and explains why while no destination exists. Write paths (register / grant / record / revoke) are covered by tests but have not been fired against the live dev database.
npm run typecheckclean.npm run test:run— 511 passed, 1 skipped; 93 of those are new across six files.UI-surface grants
Nav visibility used to come from SSO roles alone. A grant can now open one screen for one person: the access control provider asks the role policy first, and consults grants only for a surface the role denied.
Widen-only, and the order is the reason. The role policy's yes returns immediately, so a grant can only raise the floor. Revoking one returns someone to exactly what their role gives them, and a failed lookup denies — leaving the role's answer standing — so an access-control outage cannot lock anyone out.
Two things a surface grant deliberately cannot do:
read, so onlylistandshowconsult it. Letting it widen create/edit/delete would turn "can see this nav item" into edit rights.can()runs for every nav item on every render, so answers are cached per surface for the session and concurrent callers share one request; only already-denied surfaces are asked about. The cache is cleared on logout — it holds answers about one caller, and the next person to sign in on this tab must not inherit them.Consumes
GET /access/decision?capability=read&ui_surface=..., which is viewer-level and answers about the caller themselves./access/grantis admin-only and could not answer this for a non-admin.Needs
DataIntegrationGroup/OcotilloAPI#899, which adds theui_surfaceaxis.