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
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""add ui_surface to permission_grant

A grant could reach data. Now it can also open a screen: `ui_surface` names a
navigation item or page in the admin UI, using the same resource identifier the
UI already checks, so a term and a nav item cannot drift apart.

`data_type` becomes nullable to make room for it. That does **not** relax the
no-wildcard rule -- a grant still names exactly one subject, and the XOR between
`data_type` and `ui_surface` is enforced in domain/access.py before any row is
written, along with the rule that a surface grant is always global. The rules
live there rather than in a check constraint so they hold for every writer and
read as one sentence; see db/permission_grant.py.

Existing rows all carry a data_type and are untouched: nothing in the table
means "all", before or after this migration.

`ui_surface` is a lexicon category seeded from core/lexicon.json by
init_lexicon, not an enum type, so adding a screen later is not a migration.

Revision ID: a396d7d9928d
Revises: 79a3ab24627e
Create Date: 2026-08-27 18:20:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "a396d7d9928d"
down_revision: Union[str, Sequence[str], None] = "79a3ab24627e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"permission_grant",
sa.Column("ui_surface", sa.String(length=100), nullable=True),
)
op.create_foreign_key(
"fk_permission_grant_ui_surface_lexicon_term",
"permission_grant",
"lexicon_term",
["ui_surface"],
["term"],
onupdate="CASCADE",
)
op.alter_column("permission_grant", "data_type", nullable=True)


def downgrade() -> None:
# A surface grant has no data_type to fall back to, so it cannot survive a
# column that is NOT NULL again. Dropping those rows is the honest
# downgrade: they are grants this schema has no way to express, and
# leaving them with an invented data_type would grant data access nobody
# asked for.
op.execute("DELETE FROM permission_grant WHERE ui_surface IS NOT NULL")
op.alter_column("permission_grant", "data_type", nullable=False)
op.drop_constraint(
"fk_permission_grant_ui_surface_lexicon_term",
"permission_grant",
type_="foreignkey",
)
op.drop_column("permission_grant", "ui_surface")


# ============= EOF =============================================
45 changes: 39 additions & 6 deletions api/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
revoke_consent,
revoke_grant,
)
from domain.access import (
AmbiguousGrantSubject,
MissingDataType,
ScopedSurfaceGrant,
)
from services.exceptions_helper import PydanticStyleException
from services.visibility import (
destination_by_slug,
Expand Down Expand Up @@ -120,7 +125,8 @@ def create_permission_grant(
) -> PermissionGrantResponse:
"""Grant a principal one capability over one data type within one scope.

The grant names its data type; there is no wildcard, so a data type added
The grant names its data type, or the UI surface it opens -- exactly one.
There is no wildcard in either vocabulary, so a data type or screen added
later is never covered by this row.
"""
try:
Expand All @@ -132,11 +138,22 @@ def create_permission_grant(
capability=payload.capability.value,
scope_type=payload.scope_type.value,
scope_id=payload.scope_id,
data_type=payload.data_type.value,
data_type=payload.data_type.value if payload.data_type else None,
ui_surface=payload.ui_surface.value if payload.ui_surface else None,
starts_at=payload.starts_at,
ends_at=payload.ends_at,
reason=payload.reason,
)
except ScopedSurfaceGrant as exception:
raise _invalid("scope_type", str(exception), payload.scope_type.value)
except (AmbiguousGrantSubject, MissingDataType) as exception:
# `.value`, not the enum: the detail is serialized to JSON, and a bare
# Enum member is not serializable.
raise _invalid(
"data_type",
str(exception),
payload.data_type.value if payload.data_type else None,
)
except ValueError as exception:
raise _invalid("scope_id", str(exception), payload.scope_id)

Expand Down Expand Up @@ -175,6 +192,7 @@ def get_permission_grants(
),
capability: str = Query(default=None),
data_type: str = Query(default=None),
ui_surface: str = Query(default=None),
scope_type: str = Query(default=None),
include_revoked: bool = Query(default=False),
) -> list[PermissionGrantResponse]:
Expand All @@ -190,6 +208,8 @@ def get_permission_grants(
statement = statement.where(PermissionGrant.capability == capability)
if data_type is not None:
statement = statement.where(PermissionGrant.data_type == data_type)
if ui_surface is not None:
statement = statement.where(PermissionGrant.ui_surface == ui_surface)
if scope_type is not None:
statement = statement.where(PermissionGrant.scope_type == scope_type)
if not include_revoked:
Expand All @@ -206,28 +226,41 @@ def get_access_decision(
session: session_dependency,
user: viewer_dependency,
capability: str = Query(),
data_type: str = Query(),
data_type: str = Query(default=None),
ui_surface: str = Query(default=None),
thing_id: int = Query(default=None),
on_date: date = Query(default=None),
) -> AccessDecision:
"""May the caller do this? Answered by the one visibility layer.

Default deny: an unrecognized capability or a caller the token says
nothing about gets False rather than an error, because a question this
layer cannot answer is not a yes.
Ask about a data type or about a UI surface, not both: they are separate
questions, and a call naming both would have two answers.

Default deny: an unrecognized capability, a caller the token says nothing
about, or a call naming neither subject gets False rather than an error,
because a question this layer cannot answer is not a yes.
"""
if data_type and ui_surface:
raise _invalid(
"ui_surface",
"Ask about a data type or a UI surface, not both.",
ui_surface,
)

principals = principals_from_payload(user)
return AccessDecision(
allowed=may(
session,
principals,
capability=capability,
data_type=data_type,
ui_surface=ui_surface,
thing_id=thing_id,
on_date=on_date,
),
capability=capability,
data_type=data_type,
ui_surface=ui_surface,
thing_id=thing_id,
principals=[f"{kind}:{identifier}" for kind, identifier in principals],
)
Expand Down
1 change: 1 addition & 0 deletions core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,6 @@
Capability: type[Enum] = build_enum_from_lexicon_category("capability")
GrantScopeType: type[Enum] = build_enum_from_lexicon_category("grant_scope_type")
AccessDataType: type[Enum] = build_enum_from_lexicon_category("access_data_type")
UISurface: type[Enum] = build_enum_from_lexicon_category("ui_surface")
DestinationKind: type[Enum] = build_enum_from_lexicon_category("destination_kind")
# ============= EOF =============================================
81 changes: 81 additions & 0 deletions core/lexicon.json
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,10 @@
"name": "access_data_type",
"description": "The kind of data a grant or a publication consent names. Deliberately coarse, and deliberately without a term meaning 'all' (ADR5, PUB-U11)."
},
{
"name": "ui_surface",
"description": "A screen or navigation item in the admin UI that a grant can open up. Named by the resource identifier the UI already checks, so a term and a nav item cannot drift apart (ADR5)."
},
{
"name": "destination_kind",
"description": "What sort of place published data is offered to: the public web, a harvesting agency, a partner with a standing connection (ADR5)."
Expand Down Expand Up @@ -8621,6 +8625,83 @@
"term": "site metadata",
"definition": "Where the thing is and what it is: name, location, type, status."
},
{
"categories": [
"ui_surface"
],
"term": "ocotillo.map",
"definition": "The map view."
},
{
"categories": [
"ui_surface"
],
"term": "ocotillo.thing-well",
"definition": "The wells list and well records."
},
{
"categories": [
"ui_surface"
],
"term": "ocotillo.thing-well-projects",
"definition": "The projects list."
},
{
"categories": [
"ui_surface"
],
"term": "ocotillo.thing-well-batch-export",
"definition": "Field sheet batch export."
},
{
"categories": [
"ui_surface"
],
"term": "ocotillo.contact",
"definition": "The contacts list and contact records."
},
{
"categories": [
"ui_surface"
],
"term": "ocotillo.collections",
"definition": "The published OGC datasets catalogue."
},
{
"categories": [
"ui_surface"
],
"term": "ocotillo.asset-unassociated",
"definition": "Assets not yet attached to a thing."
},
{
"categories": [
"ui_surface"
],
"term": "ocotillo.location",
"definition": "The locations list."
},
{
"categories": [
"ui_surface"
],
"term": "ocotillo.lexicon",
"definition": "The controlled-vocabulary editor."
},
{
"categories": [
"ui_surface"
],
"term": "ocotillo.hydrograph-correction",
"definition": "The hydrograph correction workbench."
},
{
"categories": [
"ui_surface"
],
"term": "ocotillo.access-grants",
"definition": "This access-control console."
},
{
"categories": [
"destination_kind"
Expand Down
22 changes: 18 additions & 4 deletions db/permission_grant.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,22 @@
``db/publication_consent.py``, a separate table with separate governance and
the same grammar. Both are evaluated by ``services/visibility.py``.

A grant names exactly one subject: either a ``data_type`` (what data it
reaches) or a ``ui_surface`` (what screen it opens). Both axes are lexicon
terms, both are nullable in the table, and the XOR between them is enforced in
``domain/access.py`` rather than as a check constraint, so the rule holds for
every writer and reads as one sentence.

Invariants, enforced in ``domain/access.py`` before a row is written:

* ``data_type`` is never null and there is no term meaning "all", so a data
type added later is not covered by an existing grant.
* Exactly one of ``data_type`` / ``ui_surface`` is set. Neither is a wildcard
and there is no term meaning "all", so a data type or screen added later is
not covered by an existing grant.
* A ``global`` grant carries no ``scope_id``; a ``group`` or ``thing`` grant
requires one.
* A ``ui_surface`` grant is always ``global``. Navigation is app-wide -- the UI
never asks "may I see this nav item *for this well*" -- so a scoped screen
grant would be a row that could never match.
* Expiry is read at use. Nothing sweeps this table, so a missed job cannot
leave a grant standing past its end date.
"""
Expand Down Expand Up @@ -58,7 +68,10 @@ class PermissionGrant(Base, AutoBaseMixin):
capability: Mapped[str] = lexicon_term(nullable=False)
scope_type: Mapped[str] = lexicon_term(nullable=False)
scope_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
data_type: Mapped[str] = lexicon_term(nullable=False)
# Exactly one of these two is set; see the module docstring. Nullable in
# the table, XOR in domain/access.py.
data_type: Mapped[Optional[str]] = lexicon_term(nullable=True)
ui_surface: Mapped[Optional[str]] = lexicon_term(nullable=True)

# --- When ---
starts_at: Mapped[date] = mapped_column(nullable=False)
Expand Down Expand Up @@ -89,9 +102,10 @@ class PermissionGrant(Base, AutoBaseMixin):
)

def __str__(self):
subject = self.data_type or self.ui_surface
return (
f"{self.principal_type}:{self.principal_id} may {self.capability} "
f"{self.data_type} ({self.scope_type})"
f"{subject} ({self.scope_type})"
)


Expand Down
Loading
Loading