Skip to content

feat(access): add an operations console for access control - #365

Draft
jirhiker wants to merge 7 commits into
stagingfrom
feature/grants-operations-console
Draft

feat(access): add an operations console for access control#365
jirhiker wants to merge 7 commits into
stagingfrom
feature/grants-operations-console

Conversation

@jirhiker

@jirhiker jirhiker commented Aug 28, 2026

Copy link
Copy Markdown
Member

What

An admin-only console at /access/* covering the ADR5 /access surface, in three tabs:

Tab Route Does
Grants /access/grants Who may read / enter / correct / administer each kind of data, in what scope, for how long. Audit view with filters; grant and revoke.
Destinations /access/destinations The places published data is offered to. List, register, and expand to see what each may currently read.
Consent /access/consent Where an owner agreed to publish one data type to one destination. Record and withdraw, per thing.

They 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 /access routes 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:

GET  /access/grant?principal_id&capability&data_type&scope_type&include_revoked
POST /access/grant
POST /access/grant/{grant_id}/revocation
GET  /access/destination                    POST /access/destination
GET  /access/destination/{slug}/thing
GET  /access/consent?thing_id&include_revoked
POST /access/consent
POST /access/consent/{consent_id}/revocation

This UI should not merge ahead of that API landing on staging.

Design notes

Grants are audit-first, consent is thing-scoped. GET /access/grant takes every filter as optional, so the bare call is the admin-wide view. GET /access/consent still requires thing_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 nullable ends_at, and a nullable revoked_at, and neither stores a status: what a row means depends on the day it is read. That derivation lives once in accessLifecycle.ts so 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, and accessConsent.ts hand-write their schemas, the same way gisArtifacts.ts does. The committed openapi-auth.json snapshot predates the /access routes, so src/generated cannot describe them. Replace with generated schemas once /access is in the deployed spec.

Verification

Exercised against a local API actually serving /access:

GET /access/grant?include_revoked=false                       → 200
GET /access/grant?include_revoked=false&capability=administer → 200
GET /access/grant?include_revoked=true                        → 200
GET /access/destination                                       → 200

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 typecheck clean.
  • npm run test:run — 511 passed, 1 skipped; 93 of those are new across six files.
  • Lint clean on all 25 touched 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:

  • Confer writing. The grant is read, so only list and show consult it. Letting it widen create/edit/delete would turn "can see this nav item" into edit rights.
  • Reveal a WIP surface. Those are hidden because they are unfinished, not 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 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/grant is admin-only and could not answer this for a non-admin.

Needs DataIntegrationGroup/OcotilloAPI#899, which adds the ui_surface axis.

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>
@github-actions

Copy link
Copy Markdown

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>
@jirhiker jirhiker changed the title feat(access): add an operations console for permission grants feat(access): add an operations console for access control Aug 28, 2026
@github-actions

Copy link
Copy Markdown

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>
@github-actions

Copy link
Copy Markdown

… 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>
@github-actions

Copy link
Copy Markdown

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>
@github-actions

Copy link
Copy Markdown

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>
@github-actions

Copy link
Copy Markdown

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>
@github-actions

Copy link
Copy Markdown

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.

1 participant