feat(access): let a grant open a UI surface, not only reach data - #899
Open
jirhiker wants to merge 1 commit into
Open
feat(access): let a grant open a UI surface, not only reach data#899jirhiker wants to merge 1 commit into
jirhiker wants to merge 1 commit into
Conversation
A permission grant could name a data type. It can now name a `ui_surface` instead: a screen or navigation item in the admin UI, so "this person may see the Lexicon editor" is a grant rather than a role change in Authentik. Surfaces are named by the resource identifier the UI already checks (`ocotillo.lexicon`, `ocotillo.thing-well`, ...), so a lexicon term and a nav item cannot drift apart. They are lexicon terms, not an enum type, so adding a screen later is a seed, not a migration. ## The no-wildcard rule is kept, not relaxed `data_type` becomes nullable to make room for `ui_surface`, which reads like a loosened invariant and is not one. A grant still names exactly one subject: the XOR between the two columns is enforced in domain/access.py before any row is written, so it holds for every writer rather than only for the route. Neither vocabulary has a term meaning "all", so a data type or a screen added next year is still never covered by an existing grant. A grant naming both is refused. It would be two grants wearing one revocation, and revoking the data half would silently take the screen away too — write two grants so each can be revoked on its own. ## A surface grant is always global Navigation is app-wide: the UI asks "may this caller see this screen", never "for this well". A group- or thing-scoped surface grant could not match any request the UI makes, so it is refused at the door rather than stored as a row that silently never applies. ## Matching `_subject_matches` never matches an unasked axis. Both a data grant and a surface request carry `None` on the axis the other names, and matching on `None == None` would have made a water-level grant answer "may I see the Lexicon editor" with yes. `may()` takes `ui_surface` alongside `data_type`, and `may_see_surface()` is the thin reading of it the UI will use. A call naming neither subject is a no, not an error: a question this layer cannot answer is not a yes. `GET /access/decision` refuses a call naming both — two questions have two answers — and `GET /access/grant` filters by `ui_surface`. ## Downgrade A surface grant has no data_type to fall back to, so it cannot survive the column being NOT NULL again. The downgrade deletes those rows, which is the honest reading: they are grants the old schema cannot express, and giving them an invented data_type would grant data access nobody asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
|
Your pull request is automatically being deployed to Dagster Cloud.
|
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
A permission grant could reach data. Now it can also open a screen.
ui_surfacenames a navigation item or page in the admin UI —ocotillo.lexicon,ocotillo.location, and so on — using the same resource identifier the UI already checks incanAccessResource, so a lexicon term and a nav item cannot drift apart.Based on
feat/scope-based-access-control, notstaging— it needs the ADR5 access tables and the list-all-grants route.The no-wildcard rule is kept, not relaxed
data_typebecomes nullable, which looks like a loosening. It is not. The rule it carried — a grant names its subject, and there is no term meaning "all" — is replaced by a strictly equivalent one:Exactly one of
data_type/ui_surfaceis set. Never neither, never both.MissingDataType, unchanged in spirit: a blanket grant is what published data nobody agreed to publish.AmbiguousGrantSubject. A row naming both would be two grants wearing one revocation, and revoking the data half would silently take the screen away too. Write two grants so each can be revoked on its own.The XOR is enforced in
domain/access.pybefore any row is written, not as a check constraint, for the reason the scope rule is: so it holds for every writer and reads as one sentence.A surface grant is always global
New
ScopedSurfaceGrant. Navigation is app-wide — the UI asks "may this caller see this screen", never "for this well" — so athing- orgroup-scoped surface grant could not match any request the UI makes. It is refused at the door rather than stored as a row that silently never applies.Matching never treats
Noneas a matchA data grant and a screen request both carry
Noneon the axis not being asked about. Matching onNone == Nonewould make a data grant answer a screen question, so_subject_matchesrequires the asked axis to be present and equal. A request naming neither subject is a no — default deny, because a question this layer cannot answer is not a yes.Surface area
POST /access/grantui_surface; 422 on both-or-neither, and on a scoped surface grantGET /access/grantui_surfacefilterGET /access/decisionui_surface;data_typenow optional; 422 if both are asked at oncePlus
services.visibility.may_see_surface, a thin reading ofmay— surface grants are always global and alwaysread, so callers do not restate either.Widen-only by construction. This answers whether a grant opens a screen. The UI falls back to its role policy when the answer is no, so a missing grant can never take away what a role already allows, and a grants outage cannot lock an admin out.
Migration
a396d7d9928d. Addsui_surface(nullable, FK tolexicon_term), makesdata_typenullable. Existing rows all carry adata_typeand are untouched.The downgrade deletes surface grants before restoring
NOT NULL. That is the honest direction: they are grants the old schema has no way to express, and giving them an inventeddata_typewould grant data access nobody asked for. Both directions were run against a live database, with a surface grant present, and the row was removed as documented.ui_surfaceis a lexicon category seeded fromcore/lexicon.json, not an enum type, so adding a screen later is not a migration.Verification
tests/test_domain_access.py— 46 pass, 13 new: the XOR, the global-only rule,None-is-not-a-match in both directions, and that a surface grant expires and revokes like any other.tests/test_access.py— 36 pass, 11 new, through the routes: grant, filter, decide, revoke, and both 422s.tests/transfers/test_contact_with_multiple_wells.pyare a missing localgcs_credentials.jsonpointing into another checkout — unrelated, and failing the same way before this branch.blackclean.flake8reports only E501, which the repo already produces on these files untouched (black targets 88, there is no flake8 config).One defect this caught in itself, worth noting: the first cut passed a bare
Enuminto the 422 detail, which is not JSON serializable. Fixed, and the route test that found it is in the suite.Consumer
DataIntegrationGroup/OcotilloUI#365adds the access console. The UI side of surface grants goes on top of this.