From 569751e616c3a03c53a989a5003d901e8f7c586c Mon Sep 17 00:00:00 2001 From: jakeross Date: Fri, 28 Aug 2026 12:13:36 -0700 Subject: [PATCH 1/3] docs: draft design for user-managed API keys Companion to OcotilloUI#360, whose settings page ships a generate / rename / revoke / one-time-reveal card against local component state with no endpoints behind it. Design only -- no model, routes, or migration yet. Records three decisions worth settling before implementation: - Key creation must be gated on the OGCInternal group, not a general role. A key is a pre-authorized stand-in for that group, so gating creation on viewer_dependency would let any Viewer mint themselves access to the unfiltered internal collections. - v1 keys authorize /ogcapi-internal only. Making a key act as its owner would require authenticated() to accept a non-JWT credential and synthesize a claims payload, putting every *_dependency behind a static string. - The ADR5 access layer is not on staging, so a key cannot be modeled as a grant principal yet. That is the reason to keep v1 narrow. Also notes that env-var keys in INTERNAL_OGC_API_KEYS stay live alongside the table, since existing holders have them saved in ArcGIS Pro connection dialogs. Co-Authored-By: Claude Opus 5 --- docs/api-key-management.md | 228 +++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 docs/api-key-management.md diff --git a/docs/api-key-management.md b/docs/api-key-management.md new file mode 100644 index 00000000..e0974783 --- /dev/null +++ b/docs/api-key-management.md @@ -0,0 +1,228 @@ +# API key management — design draft + +Status: **draft, not 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 + +**Recommendation: 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), nullable | NULL = no expiry. | +| `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. + +## 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. + +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 + +Matches `ApiKey` in `src/utils/apiKeys.ts` so the card swaps local state for +calls without reshaping: + +```json +{ + "id": "12", + "name": "Field laptop", + "token": "ocot_…", // POST only, never again + "tokenPreview": "ocot_ab12cd…wxyz", + "createdAt": "2026-08-28T17:04:00Z", + "lastUsedAt": null, + "revokedAt": null +} +``` + +The UI's `id` is a string; the column is an int. Serialize as a string rather +than changing the UI type — it already treats the id as opaque. + +## 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. +- 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. + +## Open questions + +1. **Should keys be scoped to `OGCInternal` at all in v1**, or does the settings + page need general-purpose read keys for scripts against `/thing`, `/location` + etc.? The second is a much larger change and is what the "acts as its owner" + section rules out. +2. **Expiry policy.** The table supports it; nothing above sets a default. A + 90-day default with explicit opt-out is defensible and is a one-line change to + the create route. +3. **Should PR #360's card hide itself for non-`OGCInternal` accounts**, or show + a disabled state explaining the requirement? Either way the UI changes. From 2fa5cb150af0f8f213556748e7478821648404bb Mon Sep 17 00:00:00 2001 From: jakeross Date: Fri, 28 Aug 2026 12:16:47 -0700 Subject: [PATCH 2/3] docs: settle API key scope and expiry Both open questions answered: - Scope is internal-only. Keys authorize /ogcapi-internal and nothing else, as drafted. - Every key expires, 365 days after creation. expires_at becomes NOT NULL and 365 days is also the maximum, so there is no way to request a key that never expires; operator-issued INTERNAL_OGC_API_KEYS entries stay the escape hatch for a longer-lived credential. Records two consequences of a lifetime that long. It is a backstop against abandoned keys rather than a security control -- the real control is revocation, which the table makes instant. And with no notification infrastructure a key dies silently, so expires_at has to reach the UI with a warning as it approaches. Open questions section replaced with the resulting asks on OcotilloUI#360: a new expiresAt field, and an empty state for accounts without the OGCInternal group. Co-Authored-By: Claude Opus 5 --- docs/api-key-management.md | 75 +++++++++++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 13 deletions(-) diff --git a/docs/api-key-management.md b/docs/api-key-management.md index e0974783..f9350eb9 100644 --- a/docs/api-key-management.md +++ b/docs/api-key-management.md @@ -51,7 +51,7 @@ Generate button that 403s. That is a change to PR #360. ## Scope: what a key authorizes -**Recommendation: v1 keys authorize `/ogcapi-internal` and nothing else.** +**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 @@ -78,7 +78,7 @@ New model `db/api_key.py`, table `api_key`. Not in `db/permission.py`: that | `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), nullable | NULL = no expiry. | +| `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. | @@ -104,6 +104,43 @@ Stored as unsalted SHA-256, deliberately, not bcrypt/argon2: - 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`: @@ -168,6 +205,7 @@ calls without reshaping: "token": "ocot_…", // POST only, never again "tokenPreview": "ocot_ab12cd…wxyz", "createdAt": "2026-08-28T17:04:00Z", + "expiresAt": "2027-08-28T17:04:00Z", "lastUsedAt": null, "revokedAt": null } @@ -207,6 +245,8 @@ back to its inert placeholder. - 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. @@ -215,14 +255,23 @@ back to its inert placeholder. - `tests/test_authorization.py`'s anonymous-route allowlist is unchanged: every new route is gated. -## Open questions - -1. **Should keys be scoped to `OGCInternal` at all in v1**, or does the settings - page need general-purpose read keys for scripts against `/thing`, `/location` - etc.? The second is a much larger change and is what the "acts as its owner" - section rules out. -2. **Expiry policy.** The table supports it; nothing above sets a default. A - 90-day default with explicit opt-out is defensible and is a one-line change to - the create route. -3. **Should PR #360's card hide itself for non-`OGCInternal` accounts**, or show - a disabled state explaining the requirement? Either way the UI changes. +## 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. **`expiresAt` is a new field** on the `ApiKey` type in `src/utils/apiKeys.ts`, + shown in the table and warned about 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 string, as the UI already assumes; the column is an int + serialized as one. + +## 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. From 161fc0dde4fcb2a00ce91638364d7a0a365ce836 Mon Sep 17 00:00:00 2001 From: jakeross Date: Fri, 28 Aug 2026 12:41:30 -0700 Subject: [PATCH 3/3] feat(api_key): user-issued API keys for /ogcapi-internal The internal OGC mount already accepted a static API key, but those live as label:sha256hex entries in the INTERNAL_OGC_API_KEYS environment variable, rendered from Secret Manager at deploy time. Revoking one requires a redeploy, and a key is tied to a person only by an unenforced label. This adds keys that users issue for themselves and that revoke on the next request. Backend for the settings card in OcotilloUI#360, which currently runs the whole generate / rename / revoke / reveal flow against local component state. domain/api_key.py token shape, digesting, expiry, usability db/api_key.py the api_key table, plus its migration services/ resolve_api_key(), and the variant that opens its own session for the ASGI middleware api/, schemas/ POST / GET / PATCH / DELETE on /api_key Three decisions worth knowing: Creation is gated on 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; gating on viewer_dependency would let any Viewer issue themselves access to the unfiltered internal collections. This is the first Depends()-shaped consumer of INTERNAL_OGC_GROUP. Keys authorize /ogcapi-internal and nothing else. Making a key act as its owner would mean authenticated() accepting a non-JWT credential and synthesizing a claims payload, which puts every *_dependency behind a static string. The scope column makes widening a value rather than a migration. Every key expires, 365 days out by default and by ceiling. That length is a backstop against keys nobody remembers holding, not a security control -- the control is revocation, which is now immediate. Tokens are stored as unsalted SHA-256: the secret is 256 bits of CSPRNG output, so a salt buys nothing, and verification stays one indexed lookup rather than a KDF per candidate row. The middleware checks the environment-variable keys first, then the table, then falls through to the JWT path. A database failure logs and falls through rather than 500ing, so a bad pool degrades the mount to bearer-JWT-only instead of taking it down. The session is closed before the downstream call, since the mount streams paginated GeoJSON up to max_items 10000 and a held session would pin a pool connection for the whole response. last_used_at is written at most once every 15 minutes so paging does not write per page. Responses are snake_case, unlike the camelCase ApiKey type in OcotilloUI's src/utils/apiKeys.ts -- that file was written with no server behind it, and mapping it there beats one router disagreeing with the rest of the API. The UI also needs expires_at rendered with a warning as it nears, and an empty state for accounts without OGCInternal. 41 new tests. Full suite green: 1200 passed. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 10 + .../d0e1f2a3b4c5_add_api_key_table.py | 79 ++++ api/api_key.py | 249 ++++++++++ core/dependencies.py | 19 +- core/initializers.py | 2 + core/internal_ogc_auth.py | 41 +- db/__init__.py | 1 + db/api_key.py | 109 +++++ docs/api-key-management.md | 57 ++- docs/internal-ogc-desktop-gis.md | 33 +- domain/api_key.py | 180 +++++++ schemas/api_key.py | 86 ++++ services/api_key_auth.py | 133 ++++++ tests/test_api_key.py | 440 ++++++++++++++++++ tests/test_authorization.py | 1 + tests/test_internal_ogc_auth.py | 149 ++++++ 16 files changed, 1555 insertions(+), 34 deletions(-) create mode 100644 alembic/versions/d0e1f2a3b4c5_add_api_key_table.py create mode 100644 api/api_key.py create mode 100644 db/api_key.py create mode 100644 domain/api_key.py create mode 100644 schemas/api_key.py create mode 100644 services/api_key_auth.py create mode 100644 tests/test_api_key.py diff --git a/CLAUDE.md b/CLAUDE.md index a5f9358c..740c59a4 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 00000000..aae213d2 --- /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 00000000..af112956 --- /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 95d11f3c..193ecedf 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 9f419caa..bebca80f 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 3d363198..03b3162c 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 4e2e7fb3..22cfeb78 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 00000000..ccd034cd --- /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 index f9350eb9..cf0fe14b 100644 --- a/docs/api-key-management.md +++ b/docs/api-key-management.md @@ -1,6 +1,6 @@ # API key management — design draft -Status: **draft, not implemented.** Branch `feat/api-key-management`. +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 @@ -189,30 +189,37 @@ 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 -Matches `ApiKey` in `src/utils/apiKeys.ts` so the card swaps local state for -calls without reshaping: - ```json { - "id": "12", + "id": 12, "name": "Field laptop", "token": "ocot_…", // POST only, never again - "tokenPreview": "ocot_ab12cd…wxyz", - "createdAt": "2026-08-28T17:04:00Z", - "expiresAt": "2027-08-28T17:04:00Z", - "lastUsedAt": null, - "revokedAt": null + "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 } ``` -The UI's `id` is a string; the column is an int. Serialize as a string rather -than changing the UI type — it already treats the id as opaque. +**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 @@ -255,20 +262,36 @@ back to its inert placeholder. - `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. **`expiresAt` is a new field** on the `ApiKey` type in `src/utils/apiKeys.ts`, - shown in the table and warned about as it nears — a key that stops working a - year later with no warning is a support ticket, not a security win. +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 string, as the UI already assumes; the column is an int - serialized as one. +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 diff --git a/docs/internal-ogc-desktop-gis.md b/docs/internal-ogc-desktop-gis.md index 7efa6640..d9b32604 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 `