Skip to content

feat(access): write down the access that already exists - #908

Open
jirhiker wants to merge 4 commits into
feat/ui-surface-grantsfrom
chore/seed-legacy-access-grants
Open

feat(access): write down the access that already exists#908
jirhiker wants to merge 4 commits into
feat/ui-surface-grantsfrom
chore/seed-legacy-access-grants

Conversation

@jirhiker

@jirhiker jirhiker commented Aug 30, 2026

Copy link
Copy Markdown
Member

Stacked on #899 (feat/ui-surface-grants), which is itself stacked on
feat/scope-based-access-control. Review #899 first; the diff here is
only the last commit.

The ADR5 tables start empty and empty means default deny, so switching the
layer on would take everyone's access away at once. This writes down what
access already is — as grants and as consent rows — so the day it starts
being consulted, nobody's day changes.

What lands

data_migrations/migrations/20260829_0001_seed_legacy_access_grants.py does
both halves: the day-one role baseline from services/access_seed.py, and
one publication_consent row per (release_status='public' thing, data
type) against each baseline destination. One-shot per environment, run by
hand like every data migration here.

The baseline is five groups, spelled the way the UI spells them

AMP.Admin > AMP.Editor > AMP.Viewer, plus Lexicon.Editor and
OGC.Internal.

There used to be three orthogonal families gating on names the UI never
checked — a general Admin/Editor/Viewer set, an AMP* set, a
Lexicon* set — disjoint from src/utils/accessControl.ts, which reads the
dotted ones. A token satisfying one side could satisfy nothing on the other.

core/dependencies.py moved with the grants, because gating on one
vocabulary while granting in another means no grant ever matches. Two
consequences worth reviewing deliberately:

  • admin_* and amp_admin_* are now aliases for the same groups, kept so
    route signatures did not all have to change at once. They are not tiers.
  • The two lexicon tiers are one group, so what required lexicon admin is
    now reachable by a lexicon editor. A widening, and the consolidation asked
    for.

Important

This does not rename the groups in Authentik. Until that happens, every
role-gated route denies.
AMP.Staging keeps its capital S here while the
UI has AMP.staging; settling that needs a decoded production access token.

Screens are granted with view, not read

A grant naming a ui_surface carries view; the data verbs stay with
data_type. Enforced in domain/access.py, not left as a convention:
read over a screen would be a second spelling of the same permission, and
two spellings are two things that can disagree in a listing.

Consent is grandfathered per destination, and the destinations differ

destination kind data types
public-web public web all four
ngwmn harvester everything except water chemistry

public-web gets everything because that is what release_status='public'
already meant. NGWMN was never offered chemistry, and being able to say so is
the point of the per-data-type model.

The kind is load-bearing — it picks the audience in
core/field-allowlists.yml, where public web rounds coordinates to a
kilometre and a harvester to ten metres. The migration therefore refuses
to write against a slug already registered under a different kind rather than
publishing under one nobody chose.

This takes the grandfathering branch of PUB-D13 for the legacy data. Every
narrowing from here is a revocation somebody makes deliberately, per data
type.

Also

GET /access/grant is paginated, ordered by id so pages cannot overlap or
skip.

Warning

Breaking response shape. The listing returns the standard page envelope
(items/total/page/size) instead of a bare array. The access console
is the caller.

delete is a capability now

enter and correct covered creating and revising; nothing covered
destroying, so deleting a record fell to correct by default — which reads
as "revise" and is the one action whose mistakes cannot be read back out of
the data.

capability held in the baseline by
read May see the data. Viewer, Editor, Admin, OGC.Internal
enter May record new measurements. Editor, Admin
correct May revise existing records. Editor, Admin
delete May destroy existing records. Admin only
administer May grant and revoke access. Admin
view May open a screen. Viewer, Editor, Admin, Lexicon.Editor

delete sits at the admin tier rather than with the editing verbs because
that is where destruction is gated today: an editor may remove an asset, but
the routes deleting the records these four data types name — things, groups,
observations — are all admin- or amp-admin-gated. The baseline is 65 grants:
40 data, 25 screens.

Verification

Exercised against a 2026-07-30 production clone, not just fixtures:

  • 9,848 public things → 68,936 consent rows (4 types × public-web, 3 ×
    ngwmn), 65 grants, one audit row per write
  • second run creates nothing — idempotent on real data
  • the lexicon guard caught the real prerequisite: alembic never seeds lexicon
    terms, and every access vocabulary is a foreign key to
    lexicon_term.term, so oco initialize-lexicon must run before this
    migration in every environment

Full suite: 1287 passed, 84 skipped, 6 xpassed.

Deploy order

Order matters at every step here; two of them are not obvious.

  1. Rename the Authentik groups to AMP.Admin, AMP.Editor,
    AMP.Viewer, Lexicon.Editor, OGC.Internal. Until this happens every
    role-gated route denies, so it goes first, not last.
  2. oco initialize-lexicon — seeds the access vocabularies. The
    capability, scope-type, principal-type, data-type, UI-surface and
    destination-kind columns are all foreign keys to lexicon_term.term, and
    alembic never seeds terms. The migration checks for them up front and
    refuses with the missing list rather than dying on a constraint partway
    through a batch.
  3. alembic upgrade head.
  4. Restart the application. core/enums.py builds every lexicon-backed
    enum from the database at import, so a running process holds the
    vocabulary as it was when it booted. Skip this and step 5 writes rows whose
    terms the process has never heard of: the writes succeed and every
    subsequent read 500s on enum validation, with a traceback that does not
    mention the lexicon. Observed in dev on this branch.
  5. oco data-migrations run 20260829_0001_seed_legacy_access_grants.

Step 5 is idempotent and previews nothing — it writes. It will not resurrect
a seeded grant somebody revoked, and it skips any (thing, destination, data
type) that already has a live consent row.

🤖 Generated with Claude Code

jirhiker and others added 2 commits August 29, 2026 20:58
The ADR5 tables start empty and empty means default deny, so switching the
layer on would take everyone's access away at once. This records what access
already is, as grants and consent rows, so the day it starts being consulted
nobody's day changes.

Data migration `20260829_0001_seed_legacy_access_grants` does both halves:
the day-one role baseline from `services/access_seed.py`, and one
`publication_consent` row per (`release_status='public'` thing, data type)
against each baseline destination. It is one-shot per environment and run by
hand, like every data migration here.

## The baseline is five groups, spelled the way the UI spells them

`AMP.Admin` > `AMP.Editor` > `AMP.Viewer`, plus `Lexicon.Editor` and
`OGC.Internal`. There used to be three orthogonal families gating on names
the UI never checked -- a general `Admin`/`Editor`/`Viewer` set, an `AMP*`
set, a `Lexicon*` set -- disjoint from `src/utils/accessControl.ts`, which
reads the dotted ones. A token satisfying one side could satisfy nothing on
the other.

`core/dependencies.py` moved with the grants, because gating on one
vocabulary while granting in another means no grant ever matches. The
`admin_*` and `amp_admin_*` dependencies are now aliases for the same groups,
kept so route signatures did not all have to change at once. The two lexicon
tiers are one group, which widens what a lexicon editor reaches -- that is
the consolidation, not an oversight.

This does not rename the groups in Authentik. Until that happens, every
role-gated route denies.

## Screens are granted with `view`, not `read`

A grant naming a `ui_surface` carries `view`; the data verbs stay with
`data_type`. The pairing is enforced in `domain/access.py` rather than left
as a convention, because `read` over a screen would be a second spelling of
the same permission, and two spellings are two things that can disagree in a
listing.

## Consent is grandfathered per destination, and the destinations differ

`public-web` (kind `public web`) receives all four data types, because that
is what `release_status='public'` already meant. `ngwmn` (kind `harvester`)
receives everything except water chemistry: the harvester was never offered
it, and being able to say so is the point of the per-data-type model.

The kind is load-bearing -- it picks the audience in
`core/field-allowlists.yml`, where public web rounds coordinates to a
kilometre and a harvester to ten metres. So the migration refuses to write
against a slug already registered under a different kind rather than
publishing under one nobody chose.

This takes the grandfathering branch of PUB-D13 for the legacy data. Every
narrowing from here is a revocation somebody makes deliberately, per data
type, which is what the model exists to make expressible.

## Also

`GET /access/grant` is paginated, ordered by id so pages cannot overlap or
skip. The admin-wide view is dozens of rows before anybody grants anything by
hand. This changes the response from a bare array to the standard page
envelope.

Verified against a 2026-07-30 production clone: 9,848 public things, 68,936
consent rows, 61 grants, one audit row per write, and a second run that
creates nothing. The lexicon guard caught the real prerequisite there --
alembic never seeds lexicon terms, and every access vocabulary is a foreign
key to `lexicon_term.term`, so `oco initialize-lexicon` runs first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Your pull request is automatically being deployed to Dagster Cloud.

Location Status Link Updated
ocotillo-automated-ingestion View in Cloud Aug 30, 2026 at 07:28 PM (UTC)

jirhiker and others added 2 commits August 30, 2026 11:53
`enter` and `correct` covered creating and revising; nothing covered
destroying. Deleting a record fell to `correct` by default, which reads as
"revise" and is the one action whose mistakes cannot be read back out of the
data.

`delete` is a data verb, so it names a `data_type` and never a `ui_surface`,
and `domain/access.py` refuses the crossing like it does for the other four.

## Why it sits at the admin tier

The baseline gives `delete` to `AMP.Admin` alone, not to `AMP.Editor` with the
other editing verbs. That mirrors where destruction is gated today rather than
asserting a policy: an editor may remove an asset, but the routes that delete
the records these four data types name -- things, groups, observations -- are
all admin- or amp-admin-gated.

The baseline is 65 grants now: 40 data and 25 screens.

## A stale test example

`test_an_unknown_capability_is_rejected` used "delete" as its example of a
capability that does not exist. It now does, so the test was passing for a
reason that had stopped being true; it asks about "adjudicate" instead.

## Note for deploying

`core/enums.py` builds every lexicon-backed enum from the database at import,
so a running process holds the vocabulary as it was when it booted. Seeding a
term and writing rows that use it will 500 on read until the process restarts
-- the write succeeds, the read fails, and the traceback does not mention the
lexicon. `oco initialize-lexicon` and an app restart both come before this
migration runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`data_type` and `ui_surface` name one subject exactly. A console offering a
"screens only" view has a coarser question -- which kind of grant, not which
one -- and could not ask it by naming every screen in the lexicon.

`GET /access/grant?subject=ui_surface` answers it. Exactly one of the two
columns is set on any row, so "names a screen" and "names a data type" are
the same question asked either way round, and the filter is one IS NULL test
rather than a disjunction over a vocabulary.

Filtering client-side was the alternative and is wrong now that the listing is
paged: it would filter a page rather than the set, so a console asking for
screens would show however many of the first twenty-five rows happened to be
screens.

An unrecognized subject is a 422, not an empty page. Default deny is right for
authorization and wrong for a listing: answering a caller's typo with zero
rows reads as "none exist", which is the one answer an audit view must never
give by accident.

`SUBJECT_DATA_TYPE` / `SUBJECT_UI_SURFACE` live in domain/access.py next to
the capability sets, spelled as the columns are spelled, so the route
validates against the same names the model uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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