feat(access): write down the access that already exists - #908
Open
jirhiker wants to merge 4 commits into
Open
Conversation
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>
Contributor
|
Your pull request is automatically being deployed to Dagster Cloud.
|
`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>
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.
Stacked on #899 (
feat/ui-surface-grants), which is itself stacked onfeat/scope-based-access-control. Review #899 first; the diff here isonly 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.pydoesboth halves: the day-one role baseline from
services/access_seed.py, andone
publication_consentrow per (release_status='public'thing, datatype) 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, plusLexicon.EditorandOGC.Internal.There used to be three orthogonal families gating on names the UI never
checked — a general
Admin/Editor/Viewerset, anAMP*set, aLexicon*set — disjoint fromsrc/utils/accessControl.ts, which reads thedotted ones. A token satisfying one side could satisfy nothing on the other.
core/dependencies.pymoved with the grants, because gating on onevocabulary while granting in another means no grant ever matches. Two
consequences worth reviewing deliberately:
admin_*andamp_admin_*are now aliases for the same groups, kept soroute signatures did not all have to change at once. They are not tiers.
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.Stagingkeeps its capital S here while theUI has
AMP.staging; settling that needs a decoded production access token.Screens are granted with
view, notreadA grant naming a
ui_surfacecarriesview; the data verbs stay withdata_type. Enforced indomain/access.py, not left as a convention:readover a screen would be a second spelling of the same permission, andtwo spellings are two things that can disagree in a listing.
Consent is grandfathered per destination, and the destinations differ
public-webpublic webngwmnharvesterpublic-webgets everything because that is whatrelease_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 akilometre 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/grantis paginated, ordered by id so pages cannot overlap orskip.
Warning
Breaking response shape. The listing returns the standard page envelope
(
items/total/page/size) instead of a bare array. The access consoleis the caller.
deleteis a capability nowenterandcorrectcovered creating and revising; nothing covereddestroying, so deleting a record fell to
correctby default — which readsas "revise" and is the one action whose mistakes cannot be read back out of
the data.
readentercorrectdeleteadministerviewdeletesits at the admin tier rather than with the editing verbs becausethat 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:
ngwmn), 65 grants, one audit row per write
terms, and every access vocabulary is a foreign key to
lexicon_term.term, sooco initialize-lexiconmust run before thismigration 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.
AMP.Admin,AMP.Editor,AMP.Viewer,Lexicon.Editor,OGC.Internal. Until this happens everyrole-gated route denies, so it goes first, not last.
oco initialize-lexicon— seeds the access vocabularies. Thecapability, scope-type, principal-type, data-type, UI-surface and
destination-kind columns are all foreign keys to
lexicon_term.term, andalembic 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.
alembic upgrade head.core/enums.pybuilds every lexicon-backedenum 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.
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