Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/skills/code-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ router-level `dependencies=[...]`. Two failure modes to flag:
value (`user=viewer_dependency`). The latter silently disables the dependency, and FastAPI
reinterprets it as a query parameter. Flag this every time.

Role families are orthogonal: general `Admin` confers nothing in the `AMP*` or `Lexicon*`
families. Only tiers within one family nest. A diff that treats `Admin` as a superset of
`AMPEditor` is wrong.
The role groups are `AMP.Admin` > `AMP.Editor` > `AMP.Viewer`, one ladder, plus
`Lexicon.Editor`, `OGC.Internal` and `AMP.Staging` outside it. A diff that treats
`AMP.Admin` as a superset of `Lexicon.Editor` or `AMP.Staging` is wrong.

`@in_public_schema` controls anonymous OpenAPI visibility only. It grants no access and
removes no dependency; flag any use that appears to be standing in for authorization.
Expand Down
32 changes: 25 additions & 7 deletions ADR5.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ for merging those tables into the access-control model.
### Principals are already more than users

`/ogcapi-internal` is gated at the ASGI layer by `core/internal_ogc_auth.py`,
which accepts an Authentik JWT carrying `OGCInternal` **or** a static API key,
which accepts an Authentik JWT carrying `OGC.Internal` **or** a static API key,
because ArcGIS Pro cannot present a bearer token. That key is a principal with
no user behind it, and today its scope is "everything the internal mount
serves." Any model that assumes principal == person is already wrong here.
Expand All @@ -101,8 +101,9 @@ second axis, because some destinations want approved data only.
### 1. Two tables

**`permission_grant`** — internal authorization. Principal (user subject, role,
API key), capability (read, enter, correct, administer), scope (project, thing,
data type, field group), time bounds, `granted_by`, `granted_at`, `reason`.
API key), capability (read, enter, correct, delete, administer), scope
(project, thing, data type, field group), time bounds, `granted_by`,
`granted_at`, `reason`.
Governed by data services staff. Answers "is this person trusted with this."

**`publication_consent`** — landowner-facing publication. One row per
Expand Down Expand Up @@ -222,11 +223,28 @@ told.
`entered_by`.
5. **Per environment, by hand:** `oco seed-access-grants` writes the day-one
role baseline (ADR5, 5.2) -- one global grant per Authentik role,
capability and data type, so nobody's access changes when the layer starts
being consulted. Until it runs, that environment denies everyone.
Idempotent, and it does not resurrect a revoked seeded grant.
capability and data type, plus one per role and UI surface, so nobody's
access changes when the layer starts being consulted. Until it runs, that
environment denies everyone. Idempotent, and it does not resurrect a
revoked seeded grant.

Data migration `20260829_0001_seed_legacy_access_grants` runs that seeder
and grandfathers the consent half in the same pass: every thing carrying
`release_status='public'` gets a consent row per access data type against
each baseline destination — `public-web` and `ngwmn`. Both, because
`release_status` never distinguished them: a public well was in the OGC
collections and in what NGWMN harvests, and there was no way to say yes to
one and no to the other. There is now, and saying no is a revocation. **That takes the grandfathering branch of
PUB-D13** (below) for the legacy data, on the reading that
`release_status='public'` already publishes everything about a record, so
the widest consent is the one that describes what is true today. Narrowing
it is a revocation somebody makes deliberately, per data type, in the
console. Data migrations have no CD path, so this too is run by hand per
environment.
6. Console administration.
7. Healy migration, after the data owner decides grandfathering.
7. Healy migration, after the data owner decides grandfathering. The legacy
rows are grandfathered by step 5; what remains is whether Healy's wells are
re-consented per data type rather than inheriting that.

## References

Expand Down
44 changes: 31 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,20 +157,27 @@ place. Read **`ADR4.md`** before extending the layer.
The system uses **Authentik** for OAuth2 authentication with role-based access control:

**Permission Levels** (defined in `core/dependencies.py`):
- **Viewer**: Read-only access to all public entities
- **Editor**: Can modify existing records (includes Viewer permissions)
- **Admin**: Can create new records (includes Editor + Viewer permissions)
- **`AMP.Viewer`**: Read-only access to all public entities
- **`AMP.Editor`**: Can modify existing records (includes Viewer permissions)
- **`AMP.Admin`**: Can create new records (includes Editor + Viewer permissions)

The hierarchy is enforced in code, via `authenticated(any_of=[...])` group lists —
`Admin` satisfies an editor- or viewer-gated route without needing all three
`AMP.Admin` satisfies an editor- or viewer-gated route without needing all three
Authentik groups granted.

**AMP-Specific Roles**: `AMPAdmin`, `AMPEditor`, `AMPViewer` for legacy AMPAPI integration
**One ladder, dotted names.** There used to be three families gating on names
the UI never checked (`Admin`/`Editor`/`Viewer`, `AMP*`, `Lexicon*`). They are
consolidated into the dotted groups the UI already reads: `AMP.Admin`,
`AMP.Editor`, `AMP.Viewer`, plus `Lexicon.Editor` and `OGC.Internal`. The
`admin_*` and `amp_admin_*` dependencies are now aliases for the same groups —
either spelling works on a route, and neither is a separate tier.
`Lexicon.Editor` is the only lexicon group, so what required lexicon *admin* is
now reachable by a lexicon editor.

**Role families are orthogonal**: general `Admin` confers nothing in the AMP or
Lexicon families. Only tiers *within* a family nest.
**Vocabulary and the GIS mount stay outside the ladder**: `AMP.Admin` confers
nothing in `Lexicon.Editor`, `OGC.Internal`, or `AMP.Staging`.

**`AMP.Staging`** is a standalone group, not a fourth AMP tier — `AMPAdmin`
**`AMP.Staging`** is a standalone group, not a fourth AMP tier — `AMP.Admin`
does not satisfy it. It gates the hydrograph corrector's publish and range-delete
routes while the workbench is being validated against real logger files, so they
ship dark. Read **`docs/hydrograph-correction-publish.md`** before changing
Expand All @@ -194,7 +201,7 @@ that genuinely have none.

**`/ogcapi-internal` is gated outside `Depends()`.** It is a raw Starlette
Mount, so `core/internal_ogc_auth.py` gates it at the ASGI layer instead. It
accepts a bearer Authentik JWT carrying `OGCInternal`, **or** a static API key
accepts a bearer Authentik JWT carrying `OGC.Internal`, **or** a static API key
presented as a bearer token, as the Basic password, or as `?token=`. Only the
key digests are stored, as `label:sha256hex` entries in `INTERNAL_OGC_API_KEYS`
— sourced in deployed environments from the Secret Manager secret
Expand Down Expand Up @@ -246,10 +253,21 @@ The storage and the evaluator exist; the field projection does not.
**`docs/access-field-projection.md`** before touching the allowlists.
- **The role baseline is seeded by hand, per environment.**
`oco seed-access-grants` writes one global grant per (Authentik role,
capability, data type) so today's roles keep today's access; it previews by
default and needs `--apply` to write. Idempotent, and it will not resurrect
a seeded grant somebody revoked, because narrowing the baseline is the point.
Until it is run in an environment, `/access/decision` denies everyone there.
capability, data type) and one per (role, UI surface) for the five groups
that exist — `AMP.Admin`, `AMP.Editor`, `AMP.Viewer`, `OGC.Internal`,
`Lexicon.Editor` — so today's roles keep today's access; it previews by default and needs `--apply` to write.
Idempotent, and it will not resurrect a seeded grant somebody revoked,
because narrowing the baseline is the point. Until it is run in an
environment, `/access/decision` denies everyone there.
- **Legacy publication is grandfathered, not re-consented.** Data migration
`20260829_0001_seed_legacy_access_grants` runs that seeder and writes one
`publication_consent` row per (`release_status='public'` thing, access data
type) against each baseline destination — `public-web` (kind `public web`)
and `ngwmn` (kind `harvester`) — because that is what the column already
meant for both. The kind is not decoration: it picks the audience in
`core/field-allowlists.yml`, so the migration refuses to write against a
destination already registered under a different kind. One-shot per
environment and run by hand like every data migration here.
- **Default deny, no wildcards, expiry at use.** A grant with no matching row
is a no; a grant names its `data_type` (there is no term meaning "all"); and
nothing sweeps expired rows, so every check compares against the date asked
Expand Down
45 changes: 40 additions & 5 deletions api/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@
from datetime import date

from fastapi import APIRouter, Query
from fastapi_pagination.ext.sqlalchemy import paginate
from sqlalchemy import select
from starlette.status import HTTP_201_CREATED

from api.pagination import CustomPage
from core.dependencies import (
admin_dependency,
session_dependency,
Expand Down Expand Up @@ -70,8 +72,10 @@
)
from domain.access import (
AmbiguousGrantSubject,
GRANT_SUBJECTS,
MissingDataType,
ScopedSurfaceGrant,
SUBJECT_UI_SURFACE,
)
from services.exceptions_helper import PydanticStyleException
from services.visibility import (
Expand Down Expand Up @@ -193,13 +197,27 @@ def get_permission_grants(
capability: str = Query(default=None),
data_type: str = Query(default=None),
ui_surface: str = Query(default=None),
subject: str = Query(
default=None,
description="Which kind of grant: 'data_type' or 'ui_surface'",
),
scope_type: str = Query(default=None),
include_revoked: bool = Query(default=False),
) -> list[PermissionGrantResponse]:
) -> CustomPage[PermissionGrantResponse]:
"""All grants, or a narrower slice of them.

Every filter is optional, so the bare route is the admin-wide audit view;
passing ``principal_id`` narrows it to one principal, as before.

``data_type`` and ``ui_surface`` name one subject exactly. ``subject`` asks
the coarser question -- which kind of grant, not which one -- because a
console offering "screens only" cannot ask it by naming every screen, and
filtering a page client-side would filter a page rather than the set.

Paginated, because the admin-wide view is not small: the day-one baseline
alone is dozens of rows before anybody grants anything by hand, and a
console that loads every grant to show twenty-five is a page that gets
slower every time somebody uses the system correctly.
"""
statement = select(PermissionGrant)
if principal_id is not None:
Expand All @@ -210,15 +228,32 @@ def get_permission_grants(
statement = statement.where(PermissionGrant.data_type == data_type)
if ui_surface is not None:
statement = statement.where(PermissionGrant.ui_surface == ui_surface)
if subject is not None:
if subject not in GRANT_SUBJECTS:
raise _invalid(
"subject",
f"A grant covers a data type or a UI surface. Use one of "
f"{', '.join(sorted(GRANT_SUBJECTS))}.",
subject,
)
# 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.
statement = statement.where(
PermissionGrant.ui_surface.is_not(None)
if subject == SUBJECT_UI_SURFACE
else PermissionGrant.ui_surface.is_(None)
)
if scope_type is not None:
statement = statement.where(PermissionGrant.scope_type == scope_type)
if not include_revoked:
statement = statement.where(PermissionGrant.revoked_at.is_(None))

return [
PermissionGrantResponse.model_validate(row)
for row in session.execute(statement).scalars()
]
# Ordered so paging is stable. Without it Postgres may return rows in a
# different order per page and the console would show one grant twice and
# another never.
statement = statement.order_by(PermissionGrant.id)

return paginate(query=statement, conn=session)


@router.get("/decision", summary="Ask the visibility layer about yourself")
Expand Down
4 changes: 2 additions & 2 deletions api/gis_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

The internal connection file is gated, not because the file is secret, but
because the internal mount's existence is not something to advertise to
anonymous callers. Holding it still gets you nothing without an `OGCInternal`
anonymous callers. Holding it still gets you nothing without an `OGC.Internal`
API key.

Read docs/ogc-desktop-gis-artifacts.md before changing what is emitted.
Expand Down Expand Up @@ -159,7 +159,7 @@ def qgis_connections_internal(user: viewer_dependency) -> Response:
"""QGIS connections file covering the public and internal mounts.

Carries no credential. The internal entry only resolves for a client that
attaches its own `OGCInternal` API key -- see
attaches its own `OGC.Internal` API key -- see
docs/internal-ogc-desktop-gis.md for how one is issued and attached.
"""
body = qgis_connections_xml(
Expand Down
56 changes: 38 additions & 18 deletions core/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,35 +35,55 @@
+ create new objects

That hierarchy is enforced here, by `any_of=` group lists rather than by
Authentik group membership overlap: an Admin-only account satisfies an
editor- or viewer-gated route because "Admin" appears in those lists. Before
this was explicit, `authenticated(permissions=["Viewer"])` required the
literal Viewer group, so the hierarchy held only as long as whoever
provisioned the Authentik groups granted all three tiers to every admin.

The three families below are deliberately orthogonal -- general `Admin` does
not confer `AMPAdmin` or `LexiconAdmin`. Only tiers *within* a family nest.
Authentik group membership overlap: an admin account satisfies an editor- or
viewer-gated route because `AMP.Admin` appears in those lists. Before this was
explicit, `authenticated(permissions=["Viewer"])` required the literal Viewer
group, so the hierarchy held only as long as whoever provisioned the Authentik
groups granted all three tiers to every admin.

## One family, dotted names

There used to be three orthogonal families -- a general `Admin`/`Editor`/
`Viewer` set, an `AMP*` set, and a `Lexicon*` set -- gating on names the UI
never checked (`src/utils/accessControl.ts` reads `AMP.Viewer` and friends).
The two vocabularies were disjoint, which meant a token satisfying one side
could satisfy nothing on the other.

The groups are now the dotted ones, and the general family is the AMP family:
`AMP.Admin`, `AMP.Editor`, `AMP.Viewer`, plus `Lexicon.Editor` for vocabulary
and `OGC.Internal` for the desktop-GIS mount. The `admin_*` and `amp_admin_*`
dependencies therefore resolve to the same groups. Both spellings are kept so
route signatures did not all have to change in one commit; they are aliases,
not tiers, and a route may use either.

`Lexicon.Editor` is the only lexicon group: what were the `LexiconAdmin` and
`LexiconEditor` tiers are one group now, so a lexicon editor reaches the
routes that used to require lexicon admin. That is a widening, and it is the
consolidation, not an accident.

`AMP.Staging` keeps its own spelling and stays outside the ladder.
"""

# General Purpose Authentication/Permissions -----------------------------------
# Aliases for the AMP groups below; see the module docstring.

admin_function = authenticated(any_of=["Admin"])
editor_function = authenticated(any_of=["Admin", "Editor"])
viewer_function = authenticated(any_of=["Admin", "Editor", "Viewer"])
admin_function = authenticated(any_of=["AMP.Admin"])
editor_function = authenticated(any_of=["AMP.Admin", "AMP.Editor"])
viewer_function = authenticated(any_of=["AMP.Admin", "AMP.Editor", "AMP.Viewer"])


# AMP-Specific Authentication/Permissions --------------------------------------

amp_admin_function = authenticated(any_of=["AMPAdmin"])
amp_editor_function = authenticated(any_of=["AMPAdmin", "AMPEditor"])
amp_viewer_function = authenticated(any_of=["AMPAdmin", "AMPEditor", "AMPViewer"])
amp_admin_function = authenticated(any_of=["AMP.Admin"])
amp_editor_function = authenticated(any_of=["AMP.Admin", "AMP.Editor"])
amp_viewer_function = authenticated(any_of=["AMP.Admin", "AMP.Editor", "AMP.Viewer"])


# Hydrograph-Corrector Staging Permissions -------------------------------------
# The hydrograph corrector's publish and range-delete routes write and destroy
# transducer records, and the workbench driving them is still being validated
# against real logger files. `AMP.Staging` is its own group with no tier below
# it and no AMP tier above it -- an AMPAdmin does not satisfy it. Nobody holds
# it and no AMP tier above it -- an `AMP.Admin` does not satisfy it. Nobody holds
# it until it is granted in Authentik, so the routes ship dark and reachable
# only by whoever is testing them.
#
Expand All @@ -76,12 +96,12 @@

# Lexicon-Specific Authentication/Permissions ----------------------------------

lexicon_admin_function = authenticated(any_of=["LexiconAdmin"])
lexicon_editor_function = authenticated(any_of=["LexiconAdmin", "LexiconEditor"])
lexicon_admin_function = authenticated(any_of=["Lexicon.Editor"])
lexicon_editor_function = authenticated(any_of=["Lexicon.Editor"])


# OGC-Internal Authentication/Permissions --------------------------------------
# INTERNAL_OGC_GROUP ("OGCInternal") lives in core/permissions.py, not here --
# INTERNAL_OGC_GROUP ("OGC.Internal") lives in core/permissions.py, not here --
# it gates core/internal_ogc_auth.py's ASGI middleware in front of the
# /ogcapi-internal mount, which runs outside FastAPI's Depends() machinery.

Expand Down
14 changes: 14 additions & 0 deletions core/lexicon.json
Original file line number Diff line number Diff line change
Expand Up @@ -8583,6 +8583,20 @@
"term": "administer",
"definition": "May grant and revoke access."
},
{
"categories": [
"capability"
],
"term": "view",
"definition": "May open a screen. The capability a ui_surface grant carries; data is reached with read."
},
{
"categories": [
"capability"
],
"term": "delete",
"definition": "May destroy existing records. Distinct from correct, which revises them."
},
{
"categories": [
"grant_scope_type"
Expand Down
2 changes: 1 addition & 1 deletion core/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ class TokenInvalid(Exception):
# Required Authentik group for the authenticated internal OGC mount
# (/ogcapi-internal). Not Depends()-shaped like the roles above -- see the
# cross-reference note in core/dependencies.py for why it still lives here.
INTERNAL_OGC_GROUP = "OGCInternal"
INTERNAL_OGC_GROUP = "OGC.Internal"


def decode_token_payload(token: str) -> dict:
Expand Down
Loading
Loading