You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(access): write down the access that already exists
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>
0 commit comments