diff --git a/CLAUDE.md b/CLAUDE.md index a5f9358c7..740c59a44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,6 +204,16 @@ ArcGIS Pro cannot send a bearer token at all and neither desktop client can refresh an Authentik token. Read **`docs/internal-ogc-desktop-gis.md`** before changing the credential paths. +**User-issued API keys** live in the `api_key` table and are the preferred +source. `/api_key` mints, lists, renames, and revokes them; revocation takes +effect on the next request, and every key expires (365 days, default and +ceiling). Only the SHA-256 digest is stored. The routes are gated on +`internal_ogc_dependency` — the `OGCInternal` group — **not** on a general +role: a key is a pre-authorized stand-in for that group, so minting one is +exactly as privileged as holding it, and a lower gate would let a Viewer issue +themselves internal-mount access. Keys authorize `/ogcapi-internal` and nothing +else. Read **`docs/api-key-management.md`** before widening that scope. + ### OGC field descriptions Per-column `title`/`description`/unit for every collection lives in diff --git a/alembic/versions/d0e1f2a3b4c5_add_api_key_table.py b/alembic/versions/d0e1f2a3b4c5_add_api_key_table.py new file mode 100644 index 000000000..aae213d2c --- /dev/null +++ b/alembic/versions/d0e1f2a3b4c5_add_api_key_table.py @@ -0,0 +1,79 @@ +"""add the api_key table + +Personal API keys for the /ogcapi-internal mount, issued by users from the +settings page rather than by an operator. + +The mount already accepts a static key, but those live as `label:sha256hex` +entries in the INTERNAL_OGC_API_KEYS environment variable, rendered from a +Secret Manager secret at deploy time. Two consequences motivated this table: +revoking a key requires a redeploy, and a key is attributable to a person only +by an unenforced label. Rows here are owned by an Authentik `sub` and revoke on +the next request. + +Only the SHA-256 digest of a token is stored, never the token, so a dump of +this table hands over no working credentials. `token_preview` holds the leading +and trailing characters so the owner can tell two keys apart in the list. + +`expires_at` is NOT NULL -- every key expires, 365 days out by default and by +ceiling. `revoked_at` is a soft revocation: the row stays so `last_used_at` +survives revocation, since "when was this compromised key last used" is a +question that only comes up afterwards. + +Revision ID: d0e1f2a3b4c5 +Revises: c9d0e1f2a3b4 +Create Date: 2026-08-28 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d0e1f2a3b4c5" +down_revision: Union[str, Sequence[str], None] = "c9d0e1f2a3b4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "api_key", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("token_digest", sa.String(length=64), nullable=False), + sa.Column("token_preview", sa.String(length=32), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("owner_sub", sa.String(length=255), nullable=False), + sa.Column("owner_name", sa.String(length=255), nullable=True), + sa.Column("scope", sa.String(length=50), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), + # AuditMixin + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("timezone('UTC', now())"), + nullable=False, + ), + sa.Column("created_by_name", sa.String(length=255), nullable=True), + sa.Column("created_by_id", sa.String(length=255), nullable=True), + sa.Column("updated_by_name", sa.String(length=255), nullable=True), + sa.Column("updated_by_id", sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + # The authentication lookup: every keyed request to /ogcapi-internal hits + # this one. Unique both to enforce the obvious and to keep it a single-row + # index probe. + op.create_index("ix_api_key_token_digest", "api_key", ["token_digest"], unique=True) + # The list route: one owner's keys. + op.create_index("ix_api_key_owner_sub", "api_key", ["owner_sub"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_api_key_owner_sub", table_name="api_key") + op.drop_index("ix_api_key_token_digest", table_name="api_key") + op.drop_table("api_key") + + +# ============= EOF ============================================= diff --git a/api/api_key.py b/api/api_key.py new file mode 100644 index 000000000..af1129562 --- /dev/null +++ b/api/api_key.py @@ -0,0 +1,249 @@ +# =============================================================================== +# Copyright 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +""" +Personal API keys for /ogcapi-internal. + +Every route is gated on `internal_ogc_dependency` -- the OGCInternal group -- +rather than on a general role. A key is a pre-authorized stand-in for that +group, so minting one is exactly as privileged as holding it. Gating creation +on a lower tier would let a Viewer issue themselves access to the unfiltered +internal collections. + +Every route also filters on the caller's own `sub`. Somebody else's key is a +404, not a 403: whether a given id exists is not the caller's business. + +See docs/api-key-management.md. +""" + +import logging +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter +from sqlalchemy import select +from starlette.status import ( + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_404_NOT_FOUND, +) + +from core.dependencies import internal_ogc_dependency, session_dependency +from db.api_key import ApiKey +from domain.api_key import ( + SCOPE_OGC_INTERNAL, + digest_token, + expiry_for, + generate_token, + is_usable, + normalize_name, + preview_token, +) +from schemas.api_key import ( + ApiKeyResponse, + CreateApiKey, + NewApiKeyResponse, + UpdateApiKey, +) +from services.exceptions_helper import PydanticStyleException + +router = APIRouter(prefix="/api_key", tags=["api_key"]) +logger = logging.getLogger(__name__) + +# Who owns a key created while AUTHENTIK_DISABLE_AUTHENTICATION=1. The bypass +# hands the route `True` instead of a token payload, so there is no `sub` to +# own the row. It is only honored when MODE=development (see +# core/permissions.assert_auth_configuration), so this value can never appear +# in a deployed database. +DEVELOPMENT_OWNER = ("development", "development bypass") + + +def _owner(user) -> tuple[str, str | None]: + """The (sub, name) that owns keys created by this caller.""" + if isinstance(user, dict): + return user["sub"], user.get("name") + return DEVELOPMENT_OWNER + + +def _owned_key(session, key_id: int, user) -> ApiKey: + """Load one of the caller's own keys, or 404.""" + owner_sub, _ = _owner(user) + key = session.scalars( + select(ApiKey).where(ApiKey.id == key_id, ApiKey.owner_sub == owner_sub) + ).one_or_none() + if key is None: + raise PydanticStyleException( + status_code=HTTP_404_NOT_FOUND, + detail=[ + { + "loc": ["path", "api_key_id"], + "msg": f"API key with ID {key_id} not found.", + "type": "value_error", + "input": {"api_key_id": key_id}, + } + ], + ) + return key + + +# POST ========================================================================= +@router.post( + "", + summary="Issue a new API key", + status_code=HTTP_201_CREATED, +) +def create_api_key( + user: internal_ogc_dependency, + payload: CreateApiKey, + session: session_dependency, +) -> NewApiKeyResponse: + """ + Issue an API key for the calling user. + + The response is the only place the token ever appears. Only its SHA-256 + digest is stored, so it cannot be shown again or recovered -- a client that + loses it has to issue another key. + """ + owner_sub, owner_name = _owner(user) + now = datetime.now(timezone.utc) + token = generate_token() + + lifetime = ( + timedelta(days=payload.lifetime_days) + if payload.lifetime_days is not None + else None + ) + + key = ApiKey( + token_digest=digest_token(token), + token_preview=preview_token(token), + name=normalize_name(payload.name), + owner_sub=owner_sub, + owner_name=owner_name, + scope=SCOPE_OGC_INTERNAL, + expires_at=expiry_for(now, lifetime), + created_by_id=owner_sub, + created_by_name=owner_name, + ) + session.add(key) + session.commit() + session.refresh(key) + + logger.info( + "api key issued", + extra={ + "event": "api_key_issued", + "api_key_id": key.id, + "api_key_preview": key.token_preview, + }, + ) + + return NewApiKeyResponse( + **ApiKeyResponse.model_validate(key).model_dump(), token=token + ) + + +# GET ========================================================================== +@router.get("", summary="List your API keys") +def get_api_keys( + user: internal_ogc_dependency, + session: session_dependency, +) -> list[ApiKeyResponse]: + """ + List the calling user's keys, active first and newest first within each + group -- the order OcotilloUI's `sortApiKeys` expects. + + Never includes a token. Not paginated: a person holds a handful of keys, + and a CustomPage envelope would only make the settings card unwrap it. + """ + owner_sub, _ = _owner(user) + keys = session.scalars( + select(ApiKey) + .where(ApiKey.owner_sub == owner_sub) + .order_by(ApiKey.revoked_at.is_(None).desc(), ApiKey.created_at.desc()) + ).all() + return [ApiKeyResponse.model_validate(key) for key in keys] + + +# PATCH ======================================================================== +@router.patch("/{api_key_id}", summary="Rename an API key") +def update_api_key( + user: internal_ogc_dependency, + api_key_id: int, + payload: UpdateApiKey, + session: session_dependency, +) -> ApiKeyResponse: + """ + Rename one of the calling user's keys. + + Allowed on a revoked key too. The name is only a label for the person + reading the list, and being able to annotate a key you have already revoked + ("laptop, stolen") is worth more than the extra failure mode. + """ + key = _owned_key(session, api_key_id, user) + owner_sub, owner_name = _owner(user) + + key.name = normalize_name(payload.name) + key.updated_by_id = owner_sub + key.updated_by_name = owner_name + session.commit() + session.refresh(key) + + return ApiKeyResponse.model_validate(key) + + +# DELETE ======================================================================= +@router.delete( + "/{api_key_id}", + summary="Revoke an API key", + status_code=HTTP_204_NO_CONTENT, +) +def revoke_api_key( + user: internal_ogc_dependency, + api_key_id: int, + session: session_dependency, +) -> None: + """ + Revoke one of the calling user's keys. Takes effect on the next request -- + no redeploy, no cache to wait out. + + The row is kept, not deleted: `last_used_at` is the thing you want after + revoking a key you think was leaked. Revoking twice is a no-op that leaves + the original revocation time alone rather than moving it forward. + """ + key = _owned_key(session, api_key_id, user) + now = datetime.now(timezone.utc) + owner_sub, owner_name = _owner(user) + + if is_usable(key.revoked_at, key.expires_at, now): + key.revoked_at = now + key.updated_by_id = owner_sub + key.updated_by_name = owner_name + session.commit() + logger.info( + "api key revoked", + extra={ + "event": "api_key_revoked", + "api_key_id": key.id, + "api_key_preview": key.token_preview, + }, + ) + elif key.revoked_at is None: + # Expired but never revoked. Stamp it so the list stops showing it as + # merely aged and the reason it stopped working is unambiguous. + key.revoked_at = now + session.commit() + + +# ============= EOF ============================================= diff --git a/core/dependencies.py b/core/dependencies.py index 95d11f3c8..193ecedfe 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -18,7 +18,7 @@ from fastapi import Depends from sqlalchemy.orm import Session -from core.permissions import authenticated +from core.permissions import authenticated, INTERNAL_OGC_GROUP from db.engine import get_db_session session_dependency: TypeAlias = Annotated[Session, Depends(get_db_session)] @@ -81,9 +81,18 @@ # OGC-Internal Authentication/Permissions -------------------------------------- -# INTERNAL_OGC_GROUP ("OGCInternal") 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. +# INTERNAL_OGC_GROUP ("OGCInternal") still lives in core/permissions.py, where +# core/internal_ogc_auth.py's ASGI middleware reads it -- that middleware gates +# the /ogcapi-internal mount outside FastAPI's Depends() machinery. This is the +# Depends()-shaped view of the same group, for the API key routes. +# +# The key routes are gated on this group and not on a general role on purpose. +# An API key is a pre-authorized stand-in for OGCInternal: it reaches the +# unfiltered, draft-inclusive internal collections. Minting one is therefore +# exactly as privileged as holding the group, and gating creation on, say, +# viewer_dependency would let any Viewer issue themselves that access with a +# button. See docs/api-key-management.md. +internal_ogc_function = authenticated(any_of=[INTERNAL_OGC_GROUP]) # Testing-Specific Authentication/Permissions ---------------------------------- @@ -106,5 +115,7 @@ amp_staging_dependency: TypeAlias = Annotated[dict, Depends(amp_staging_function)] +internal_ogc_dependency: TypeAlias = Annotated[dict, Depends(internal_ogc_function)] + no_permission_dependency: TypeAlias = Annotated[dict, Depends(no_permission_function)] # ============= EOF ============================================= diff --git a/core/initializers.py b/core/initializers.py index 9f419caa2..bebca80fa 100644 --- a/core/initializers.py +++ b/core/initializers.py @@ -219,6 +219,7 @@ def register_api_routes(app): from api.publication import router as publication_router from api.author import router as author_router from api.asset import router as asset_router + from api.api_key import router as api_key_router from api.search import router as search_router from api.geospatial import router as geospatial_router from api.ngwmn import router as ngwmn_router @@ -229,6 +230,7 @@ def register_api_routes(app): from api.gis_artifacts import router as gis_artifacts_router app.include_router(asset_router) + app.include_router(api_key_router) app.include_router(chemistry_router) app.include_router(author_router) app.include_router(contact_router) diff --git a/core/internal_ogc_auth.py b/core/internal_ogc_auth.py index 3d3631982..03b3162c3 100644 --- a/core/internal_ogc_auth.py +++ b/core/internal_ogc_auth.py @@ -41,11 +41,20 @@ QGIS regression where OGC API - Features requests dropped the Authorization header (qgis/QGIS#60473). -The Basic and query-parameter transports carry a *static API key* (see -`api_key_label`) rather than a JWT, since neither ArcGIS nor QGIS can refresh -an Authentik access token before it expires. A bearer JWT is still accepted -and still checked for INTERNAL_OGC_GROUP membership; an API key is a -pre-authorized stand-in for that same group. +The Basic and query-parameter transports carry an *API key* rather than a JWT, +since neither ArcGIS nor QGIS can refresh an Authentik access token before it +expires. A bearer JWT is still accepted and still checked for +INTERNAL_OGC_GROUP membership; an API key is a pre-authorized stand-in for +that same group. + +Keys come from two places, checked in that order: + + * `api_key_label` -- operator-issued digests in the INTERNAL_OGC_API_KEYS + environment variable, rendered from Secret Manager at deploy time. Free to + check, but revoking one requires a redeploy. + * `_database_api_key_valid` -- user-issued keys in the api_key table, minted + from the settings page. One indexed lookup, and revocation takes effect on + the very next request. The query-parameter transport puts the secret in the request URL, which App Engine's request log records. Prefer Basic where the client supports it, and @@ -193,6 +202,23 @@ async def _send_json( await send({"type": "http.response.body", "body": body}) +def _database_api_key_valid(secret: str) -> bool: + """Whether `secret` is a live key in the api_key table. + + The user-issued counterpart to api_key_label(): same standing as an + OGCInternal group membership, but revocable on the next request instead of + on the next deploy. See docs/api-key-management.md. + + Imported lazily. This module is deliberately free of database imports at + import time -- it is loaded from core/pygeoapi.py, and pulling db.engine in + at module scope reintroduces the circular import this file was split out to + avoid. + """ + from services.api_key_auth import resolve_api_key_in_new_session + + return resolve_api_key_in_new_session(secret) is not None + + class InternalOGCAuthMiddleware: """Gates every request under `mount_path` behind an API key or INTERNAL_OGC_GROUP membership; requests to any other path pass straight @@ -235,8 +261,9 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await _send_json(send, 401, "Unauthorized", challenge=True) return - if api_key_label(secret) is None: - # Not a static key, so it has to be an Authentik access token. + if api_key_label(secret) is None and not _database_api_key_valid(secret): + # Neither an operator-issued static key nor a user-issued one from + # the api_key table, so it has to be an Authentik access token. try: payload = permissions.decode_token_payload(secret) except permissions.TokenInvalid: diff --git a/db/__init__.py b/db/__init__.py index 4e2e7fb3a..22cfeb784 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -28,6 +28,7 @@ ) from db.analysis_method import * +from db.api_key import * from db.aquifer_system import * from db.aquifer_type import * from db.asset import * diff --git a/db/api_key.py b/db/api_key.py new file mode 100644 index 000000000..ccd034cd9 --- /dev/null +++ b/db/api_key.py @@ -0,0 +1,109 @@ +# =============================================================================== +# Copyright 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +""" +Personal API keys for the /ogcapi-internal mount. + +Replaces the operator-issued digests in the INTERNAL_OGC_API_KEYS environment +variable for new keys. Those still work -- see core/internal_ogc_auth.py -- but +revoking one requires a redeploy, which is the problem this table exists to +solve. + +See docs/api-key-management.md. +""" + +from datetime import datetime +from typing import Optional + +from sqlalchemy import DateTime, Index, String +from sqlalchemy.orm import Mapped, mapped_column + +from db.base import Base, AutoBaseMixin +from domain.api_key import SCOPE_OGC_INTERNAL + + +class ApiKey(Base, AutoBaseMixin): + """A credential a user issued for themselves from the settings page. + + Deliberately not `db.permission.Permission`, which is a landowner's consent + to site access and shares nothing with this but the word. + + No ReleaseMixin: a credential is not draft-or-published content, and giving + it a release_status would put it in front of the release filters that read + that column. + """ + + # The token itself is never stored. Only this digest is, so a database dump + # does not hand over working credentials. + # Uniqueness comes from ix_api_key_token_digest below, not from a + # column-level unique=True -- that would emit a second unique index on the + # same column. + token_digest: Mapped[str] = mapped_column(String(64), nullable=False) + + # Leading and trailing characters only -- enough for the owner to tell two + # keys apart in the list, useless as a credential. + token_preview: Mapped[str] = mapped_column(String(32), nullable=False) + + name: Mapped[str] = mapped_column(String(255), nullable=False) + + # Authentik's `sub` claim. Ownership, which is what every route filters on. + # + # Kept separate from AuditMixin's created_by_id even though the two hold the + # same value today: that column records who performed the write, and an + # admin-issued-on-behalf-of path would set the two differently. Conflating + # them would silently reassign the key. + owner_sub: Mapped[str] = mapped_column(String(255), nullable=False) + + # Display only. There is no user table to join, so the name is denormalized + # at creation and may go stale if the person renames themselves upstream. + owner_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + + scope: Mapped[str] = mapped_column( + String(50), nullable=False, default=SCOPE_OGC_INTERNAL + ) + + # NOT NULL: every key expires. domain.api_key.expiry_for() sets it, and + # domain.api_key.is_expired() reads a NULL as expired, so a row written + # around the create path fails closed instead of living forever. + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + + # Soft revocation. The row survives so last_used_at and the audit columns + # survive with it -- "when was this compromised key last used" is the + # question you need answered after you revoke, not before. + revoked_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + # Written at most once per domain.api_key.LAST_USED_RESOLUTION, so paging + # through a collection does not write once per page. + last_used_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + __table_args__ = ( + # The authentication lookup. Every /ogcapi-internal request carrying a + # key hits this, so it is the one index that has to exist. + Index("ix_api_key_token_digest", "token_digest", unique=True), + # The list route: one user's keys, newest first. + Index("ix_api_key_owner_sub", "owner_sub"), + ) + + def __str__(self): + return f"{self.name} ({self.token_preview})" + + +# ============= EOF ============================================= diff --git a/docs/api-key-management.md b/docs/api-key-management.md new file mode 100644 index 000000000..cf0fe14b4 --- /dev/null +++ b/docs/api-key-management.md @@ -0,0 +1,300 @@ +# API key management — design draft + +Status: **implemented.** Branch `feat/api-key-management`. + +Companion to [OcotilloUI#360](https://github.com/DataIntegrationGroup/OcotilloUI/pull/360), +whose settings page ships a generate / rename / revoke / one-time-reveal card +driven entirely by local component state. This document is the backend that card +is waiting on. + +## What already exists + +`core/internal_ogc_auth.py` gates the `/ogcapi-internal` mount and already +accepts a static API key on three transports (bearer, Basic password, `?token=`). +Those keys live as `label:sha256hex` entries in the `INTERNAL_OGC_API_KEYS` +environment variable, rendered from the Secret Manager secret +`internal-ogc-api-keys` at deploy time. See `docs/internal-ogc-desktop-gis.md`. + +Two properties of that scheme are the reason this work exists: + +- **Revoking a key requires a redeploy.** Adding a secret version does not touch + a running instance. +- **Issuing a key requires an operator.** A user cannot get one themselves, and + the label is bookkeeping only — a key is not attributable to a person in any + enforced way. + +That doc already names the exit: *"or to a keys table in Postgres."* This is it. + +## The one security decision + +An API key today is a pre-authorized stand-in for the `OGCInternal` Authentik +group. It buys access to the unfiltered, draft-inclusive internal collections. + +So **the route that mints a key must be gated on the group the key stands in +for**, not on a general role. If key creation were gated on `viewer_dependency`, +any Viewer could mint themselves a credential that reaches the internal mount — +a privilege escalation with a UI button on it. + +```python +# core/dependencies.py +internal_ogc_function = authenticated(any_of=["OGCInternal"]) +internal_ogc_dependency: TypeAlias = Annotated[dict, Depends(internal_ogc_function)] +``` + +`INTERNAL_OGC_GROUP` currently lives in `core/permissions.py` precisely because +nothing Depends()-shaped needed it. This adds the first such consumer; the +constant stays where it is and `core/dependencies.py` references it. + +Consequence for the UI: the API keys card is only meaningful for accounts in +`OGCInternal`. Everyone else should get an empty state explaining that, not a +Generate button that 403s. That is a change to PR #360. + +## Scope: what a key authorizes + +**Decided: v1 keys authorize `/ogcapi-internal` and nothing else.** + +The alternative — a key that acts as its owner across the whole API — means +`authenticated()` has to accept a non-JWT credential and synthesize a payload +dict that every route reads claims from. Every `*_dependency` in +`core/dependencies.py` becomes reachable by a static string, and the blast radius +of a leaked key goes from "reads internal collections" to "writes anything its +owner can write." Not in the same change as the storage. + +The table carries an explicit `scope` column set to `ogc_internal` for every row, +so widening later is a value, not a migration. No wildcard value — same reason +`ADR5.md` gives for grants having no term meaning "all." + +## Storage + +New model `db/api_key.py`, table `api_key`. Not in `db/permission.py`: that +`Permission` is a landowner's site-access consent and shares nothing with this. + +| Column | Type | Notes | +| --- | --- | --- | +| `id` | int PK | `AutoBaseMixin` | +| `name` | String(255) | User-supplied label. "Field laptop", "QGIS at the office". | +| `token_digest` | String(64), unique, indexed | SHA-256 hex. Same format the env-var parser already validates, so one comparison path serves both sources. | +| `token_preview` | String(32) | `ocot_ab12cd…wxyz`. All the list view ever shows. | +| `owner_sub` | String(255), indexed | Authentik `sub` claim. The owner of record. | +| `owner_name` | String(255), nullable | Display only, from `preferred_username`/`email`. Denormalized on purpose — there is no user table to join. | +| `scope` | String(50) | `ogc_internal`. See above. | +| `expires_at` | DateTime(tz), **not null** | Always set. See "Expiry" below — there is no never-expiring key. | +| `last_used_at` | DateTime(tz), nullable | | +| `revoked_at` | DateTime(tz), nullable | Soft. The row survives revocation so `last_used_at` and the audit trail survive with it. | + +Plus `AuditMixin` for `created_at` / `created_by_*`. + +There is no user table to key against — `db/base.py`'s `User` exists for audit +strings and nothing populates it. `owner_sub` is the identity, matching how +`AuditMixin` already records people. + +### Token format and hashing + +`ocot_` + `secrets.token_urlsafe(32)`. The prefix matches what +`src/utils/apiKeys.ts` already generates, so the reveal dialog is already sized +right. + +Stored as unsalted SHA-256, deliberately, not bcrypt/argon2: + +- The secret is 256 bits of CSPRNG output, not a password. There is no dictionary + to attack and no rainbow table to build, so a salt buys nothing. +- Verification is a single indexed lookup by digest. A salted scheme forces a + scan with one KDF invocation per row, which is a denial-of-service surface on + an unauthenticated endpoint. +- It matches the existing digests in `INTERNAL_OGC_API_KEYS`, so both sources + compare identically. + +## Expiry + +**Decided: every key expires, 365 days after creation by default.** + +`domain/api_key.py` holds the rule as a plain function over plain values, per +`ADR4.md`: + +```python +DEFAULT_LIFETIME = timedelta(days=365) +MAX_LIFETIME = DEFAULT_LIFETIME + +def expiry_for(created_at: datetime, lifetime: timedelta | None = None) -> datetime +``` + +The create route accepts an optional shorter lifetime and clamps it to +`MAX_LIFETIME`. `expires_at` is `NOT NULL`: there is no way to ask for a key that +never expires. The operator-issued entries in `INTERNAL_OGC_API_KEYS` remain the +escape hatch for a credential that has to outlive that, and those are deliberately +harder to get. + +Checked at use, in `resolve_api_key()`. Nothing sweeps the table — an expired row +stays for its `last_used_at` history, same rule `ADR5.md` sets for grants. + +Two things follow from a lifetime this long, both worth stating plainly: + +- **365 days is a backstop against abandoned keys, not a security control.** At + that length the window is wide enough that expiry stops nobody who has stolen a + key. What it does buy is that a key belonging to someone who left, or saved in + an ArcGIS Pro dialog on a decommissioned laptop, eventually stops working + without anyone remembering to revoke it. The real control is revocation, which + this design makes instant — that is the whole reason the table exists. +- **A key will die silently in the middle of someone's work.** There is no email + or notification infrastructure here, so the first sign is ArcGIS Pro or QGIS + failing to connect, roughly a year after the person set it up and forgot about + it. `expires_at` must therefore be in the list response and rendered in the UI, + with a visible warning as it approaches — see the UI section below. + +## Verification path + +`services/api_key_auth.py`: + +```python +def resolve_api_key(session, secret: str, *, now: datetime) -> ApiKeyPrincipal | None +``` + +Digest the presented secret, look it up, reject if `revoked_at` is set or +`expires_at` has passed. Expiry is checked **at use** — nothing sweeps the table, +same rule `ADR5.md` sets for grants. + +`core/internal_ogc_auth.py` gains this as a second check, after the existing +`api_key_label()` env-var lookup and before the JWT decode. Order matters only +for cost: the env lookup is free, the DB lookup is a query. + +Two wrinkles, both from the middleware being raw ASGI: + +- **It has no DB session.** It opens a short-lived one from `db/engine.py`'s + factory, only on the path where the env-var check has already missed, and + closes it before calling through. It must not hold a session across the + downstream `await` — the internal mount streams paginated GeoJSON up to + `max_items: 10000`, and a session held for the response body would pin a pool + connection for the whole stream. +- **No caching.** One indexed query per internal-mount request. A TTL cache would + reintroduce exactly the revocation delay this work exists to remove; the mount's + request rate does not justify trading that away. + +`last_used_at` is written at most once per 15 minutes per key — compare in +Python, skip the write if it is already fresh — so a paging client does not turn +every page into a write. + +## Routes + +`api/api_key.py`, `APIRouter(prefix="/api_key", tags=["api_key"])`, singular to +match `/location`, `/contact`, `/asset`. + +| Method | Path | Returns | +| --- | --- | --- | +| `POST` | `/api_key` | The full token, **once**. The only response that ever contains it. | +| `GET` | `/api_key` | The caller's own keys. Preview only. | +| `PATCH` | `/api_key/{id}` | Rename. `name` is the only mutable field. | +| `DELETE` | `/api_key/{id}` | Revoke — sets `revoked_at`, returns 204. Never deletes the row. | + +Every route takes `user: internal_ogc_dependency` and filters on +`owner_sub == user["sub"]`. A key belonging to someone else is a 404, not a 403 — +existence of another person's key is not the caller's business. + +Under `AUTHENTIK_DISABLE_AUTHENTICATION=1` the dependency hands the route `True` +rather than a token payload, so there is no `sub` to own the row. Those keys are +owned by a fixed `"development"` identity (`DEVELOPMENT_OWNER` in +`api/api_key.py`). The bypass is honored only when `MODE=development`, so that +value cannot appear in a deployed database. + +No admin list-all route in v1. It is the obvious next ask, and it is a different +gate (general `Admin`, which confers nothing in this family), so it gets its own +change. + +### Response shape + +```json +{ + "id": 12, + "name": "Field laptop", + "token": "ocot_…", // POST only, never again + "token_preview": "ocot_ab12cd…wxyz", + "scope": "ogc_internal", + "created_at": "2026-08-28T17:04:00Z", + "expires_at": "2027-08-28T17:04:00Z", + "last_used_at": null, + "revoked_at": null +} +``` + +**snake_case, not the camelCase the draft proposed.** Every other route in this +API is snake_case, and `src/utils/apiKeys.ts` is camelCase only because it was +written against local component state with no server behind it. Mapping the +field names in the UI is a smaller change than making one router disagree with +the rest of the API. `id` is an int for the same reason. + +## Relationship to the ADR5 access layer + +`ADR5.md`, `services/visibility.py`, `domain/access.py`, and `api/access.py` are +**not on `staging`** — they are on `feat/ui-surface-grants`, unmerged. This +branch is based on `staging` and therefore cannot see them. + +That is fine for v1, because a key of scope `ogc_internal` never consults the +grant evaluator: the internal mount is outside the field-projection chokepoint by +design. But the moment a key authorizes anything else, a key becomes a +*principal*, and the right move is a grant whose principal is the key — not a +second authorization path. That is the reason to keep v1 narrow. + +If the grant layer merges first, `services/access_admin.py` writes an +`authorization_audit` row in the same transaction as every change, and key +issue/revoke should do the same. + +## Env-var keys after this + +Both sources stay live. `INTERNAL_OGC_API_KEYS` is not removed in this change: +existing holders have keys in ArcGIS Pro connection dialogs, and breaking them to +land a table is not worth it. Deprecation is a follow-up once holders have +re-issued from the settings page, at which point the Secret Manager secret goes +back to its inert placeholder. + +## Tests + +`tests/test_api_key.py`: + +- POST returns the token; GET never does, for the same key. +- No stored column contains the token — assert on the row, not the response. +- A revoked key 401s on `/ogcapi-internal`; an expired key 401s; a valid one passes. +- Create sets `expires_at` 365 days out by default, honors a shorter requested + lifetime, and clamps a longer one to the maximum. +- Revocation takes effect on the next request, with no redeploy and no cache flush. +- Another user's key is invisible to GET and 404s on PATCH and DELETE. +- A user without `OGCInternal` gets 403 on POST — the escalation test. +- Env-var keys still work with the table empty, and a table key still works with + `INTERNAL_OGC_API_KEYS` unset. +- `tests/test_authorization.py`'s anonymous-route allowlist is unchanged: every + new route is gated. + +## What shipped + +| File | | +| --- | --- | +| `domain/api_key.py` | Token shape, digesting, expiry, usability. No DB, no HTTP. | +| `db/api_key.py` | The `api_key` table. | +| `alembic/versions/d0e1f2a3b4c5_add_api_key_table.py` | Its migration. | +| `services/api_key_auth.py` | `resolve_api_key()` and the session-opening variant the ASGI middleware calls. | +| `core/internal_ogc_auth.py` | `_database_api_key_valid()`, checked after the environment-variable keys and before the JWT decode. | +| `core/dependencies.py` | `internal_ogc_dependency`, the first Depends()-shaped consumer of `INTERNAL_OGC_GROUP`. | +| `schemas/api_key.py`, `api/api_key.py` | The four routes. | +| `tests/test_api_key.py` | Rules, routes, and the resolver — 33 tests. | +| `tests/test_internal_ogc_auth.py` | Eight more, for the middleware path: every transport, revoked, expired, unissued, and a database failure falling back rather than 500ing. | +| `tests/test_authorization.py` | `internal_ogc_function` registered in `AUTH_DEPENDENCY_CALLABLES`, so the new routes are seen as gated rather than reported as anonymous. | + +## Changes needed in OcotilloUI#360 + +The card as written does not carry everything the API returns, and one of its +assumptions is wrong for this design. + +1. **Field names are snake_case, and `expires_at` is new** on the `ApiKey` type + in `src/utils/apiKeys.ts`. The expiry belongs in the table with a warning as + it nears — a key that stops working a year later with no warning is a support + ticket, not a security win. +2. **The card is only meaningful for `OGCInternal` accounts.** Everyone else needs + an empty state that says the keys are for desktop GIS access and how to request + the group — not a Generate button that 403s. *Still open: hide the section + entirely, or show it disabled with the explanation?* +3. `id` arrives as a number, not a string. The card treats it as opaque, so this + is a type change and nothing more. + +## Settled + +- **Scope:** internal-only. Keys authorize `/ogcapi-internal` and nothing else. +- **Expiry:** 365 days by default, `NOT NULL`, clamped as the maximum, checked at + use. diff --git a/docs/internal-ogc-desktop-gis.md b/docs/internal-ogc-desktop-gis.md index 7efa66403..d9b326045 100644 --- a/docs/internal-ogc-desktop-gis.md +++ b/docs/internal-ogc-desktop-gis.md @@ -39,7 +39,24 @@ pygeoapi, so it never lands in the `self`/`next` links pygeoapi echoes into response bodies. It is still recorded in App Engine's request log — prefer Basic where the client supports it. -## Where the keys live +## Two kinds of key + +Since `feat/api-key-management` there are two sources of API key, and the +middleware checks them in this order: + +| | Issued by | Revoked by | Expires | +| --- | --- | --- | --- | +| `INTERNAL_OGC_API_KEYS` | An operator, by hand, into Secret Manager | A redeploy | Never | +| `api_key` table | The holder, from the settings page | The next request | 365 days | + +Prefer the table for anything new. The environment variable stays supported +because existing keys are saved in ArcGIS Pro and QGIS connection dialogs and +breaking them buys nothing, but it is the slower path in every sense that +matters. See `docs/api-key-management.md`. + +The rest of this section describes the environment-variable keys. + +## Where the operator-issued keys live Only the **SHA-256 digests** are stored, never the keys themselves. The digest list lives in a Google Secret Manager secret named `internal-ogc-api-keys`, one @@ -65,20 +82,23 @@ Consequences worth knowing: The parser skips any entry whose digest is not 64 hex characters, so that value is inert and means "bearer-JWT access only". -- **Revoking a key requires a redeploy.** Adding a secret version does not - affect a running instance. If revocation ever needs to be immediate, that is - the point to switch to a runtime fetch with a TTL cache (same shape as the - JWKS cache in `core/permissions.py`) or to a keys table in Postgres. +- **Revoking one of these keys requires a redeploy.** Adding a secret version + does not affect a running instance. When revocation has to be immediate, issue + the key from the settings page instead — the `api_key` table exists for + exactly this, and a row revoked there stops working on the next request. - The deploy service account needs `roles/secretmanager.secretAccessor` on `internal-ogc-api-keys` in each project, alongside the four it already has. -## Issuing a key +## Issuing an operator key ```bash python -c "import secrets,hashlib;k=secrets.token_urlsafe(32);print('key: ',k);print('digest:',hashlib.sha256(k.encode()).hexdigest())" ``` +Only for a credential that has to outlive 365 days or belong to no particular +person; otherwise have the holder issue their own from the settings page. + Give the **key** to the user over a secure channel and keep only the digest. Append `