Coverage for node / src / stigmem_node / routes / auth.py: 94%
149 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-25 01:49 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-25 01:49 +0000
1"""Track B / B3 + C2 — OIDC → scoped API-key exchange bridge.
3POST /v1/auth/oidc/exchange validate id_token, mint/refresh a scoped key
4POST /v1/auth/keys register a caller-provided static API key (admin)
5GET /v1/auth/keys list caller's own keys
6DELETE /v1/auth/keys/{key_id} revoke a specific key
8C2 addition: when the experimental memory-garden advanced ACL plugin and its
9explicit OIDC ceiling gate are enabled, permissions are capped by the caller's
10garden membership role. admin/writer in any garden → up to ["read","write"];
11reader/no membership → ["read"].
13``POST /v1/auth/keys`` is the supported post-bootstrap path for minting
14additional static (non-OIDC) keys. It mirrors the bootstrap CLI's
15caller-generated-key posture: the caller supplies the raw key material;
16the node hashes and stores it. The endpoint requires the caller to
17hold the ``admin`` capability. Spec §3.5. Closes issue #135.
18"""
20from __future__ import annotations
22import json
23import logging
24import time
25from datetime import UTC, datetime, timedelta
26from typing import Annotated, Any
28import httpx
29import jwt
30from fastapi import APIRouter, Depends, HTTPException, Query, status
32from ..audit_event import emit as audit_emit
33from ..auth import (
34 Identity,
35 create_api_key,
36 find_api_key_id_by_raw_key,
37 register_api_key,
38 resolve_identity,
39)
40from ..db import db
41from ..memory_garden_acl_gate import oidc_permission_ceiling_enabled
42from ..models.auth import (
43 ExchangeRequest,
44 ExchangeResponse,
45 ExpiringKeyInfo,
46 KeyInfo,
47 RegisterKeyRequest,
48 RegisterKeyResponse,
49)
50from ..net_util import assert_safe_url
51from ..settings import settings
52from ..tenant import TenantIdError
54logger = logging.getLogger("stigmem.auth")
55router = APIRouter(prefix="/v1/auth", tags=["auth"])
57# ---------------------------------------------------------------------------
58# JWKS cache — keyed by issuer URL, value = (jwks_client, fetched_at_unix)
59# ---------------------------------------------------------------------------
60_JWKS_CACHE: dict[str, tuple[jwt.PyJWKClient, float]] = {}
61_JWKS_CACHE_TTL = 600 # 10 minutes
64def _get_jwks_client(issuer_url: str) -> jwt.PyJWKClient:
65 entry = _JWKS_CACHE.get(issuer_url)
66 now = time.monotonic()
67 if entry and (now - entry[1]) < _JWKS_CACHE_TTL:
68 return entry[0]
70 # Discover JWKS URI from OIDC metadata document
71 disco_url = issuer_url.rstrip("/") + "/.well-known/openid-configuration"
72 try:
73 assert_safe_url(disco_url, allow_schemes=frozenset({"https"}))
74 resp = httpx.get(disco_url, timeout=5.0, follow_redirects=False)
75 resp.raise_for_status()
76 jwks_uri: str = resp.json()["jwks_uri"]
77 assert_safe_url(jwks_uri, allow_schemes=frozenset({"https"}))
78 except Exception as exc:
79 logger.warning("OIDC discovery failed for %s: %s", issuer_url, exc)
80 raise HTTPException(
81 status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
82 detail="OIDC provider discovery failed",
83 ) from exc
85 client = jwt.PyJWKClient(jwks_uri)
86 _JWKS_CACHE[issuer_url] = (client, now)
87 return client
90def _verify_id_token(id_token: str) -> dict[str, Any]:
91 """Validate the id_token and return decoded claims. Raises HTTPException on failure."""
92 jwks_client = _get_jwks_client(settings.oidc_issuer_url)
93 try:
94 signing_key = jwks_client.get_signing_key_from_jwt(id_token)
95 claims: dict[str, Any] = jwt.decode(
96 id_token,
97 signing_key.key,
98 algorithms=settings.oidc_id_token_algorithms,
99 audience=settings.oidc_audience,
100 issuer=settings.oidc_issuer_url,
101 )
102 except jwt.ExpiredSignatureError as exc:
103 raise HTTPException(
104 status_code=status.HTTP_401_UNAUTHORIZED,
105 detail="id_token expired",
106 ) from exc
107 except jwt.InvalidAudienceError as exc:
108 raise HTTPException(
109 status_code=status.HTTP_401_UNAUTHORIZED,
110 detail="id_token audience mismatch",
111 ) from exc
112 except jwt.PyJWTError as exc:
113 raise HTTPException(
114 status_code=status.HTTP_401_UNAUTHORIZED,
115 detail=f"id_token invalid: {exc}",
116 ) from exc
117 return claims
120def _check_domain(email: str | None) -> None:
121 """Enforce oidc_allowed_domains if configured."""
122 if not settings.oidc_allowed_domains or not email:
123 return
124 allowed = {d.strip().lower() for d in settings.oidc_allowed_domains.split(",") if d.strip()}
125 if not allowed: 125 ↛ 126line 125 didn't jump to line 126 because the condition on line 125 was never true
126 return
127 domain = email.split("@", 1)[-1].lower()
128 if domain not in allowed: 128 ↛ exitline 128 didn't return from function '_check_domain' because the condition on line 128 was always true
129 raise HTTPException(
130 status_code=status.HTTP_403_FORBIDDEN,
131 detail=f"Email domain '{domain}' is not permitted",
132 )
135# ---------------------------------------------------------------------------
136# Request / response models
137# ---------------------------------------------------------------------------
139_ALLOWED_PERMISSIONS = {"read", "write"}
140_PERM_ORDER = ["read", "write"]
143def _derive_permission_ceiling(entity_uri: str) -> set[str]:
144 """Return the max permissions the entity is entitled to via garden membership.
146 admin/writer role in any garden → {"read","write"}
147 reader role only, or no membership → {"read"}
148 """
149 with db() as conn:
150 rows = conn.execute(
151 "SELECT role FROM garden_members WHERE entity_uri = ?",
152 (entity_uri,),
153 ).fetchall()
154 roles = {r["role"] for r in rows}
155 if "admin" in roles or "writer" in roles:
156 return {"read", "write"}
157 return {"read"}
160# ---------------------------------------------------------------------------
161# Static key registration (POST /v1/auth/keys) — issue #135
162#
163# Permission vocabulary kept narrow to the §3.5 set the auth module already
164# defines. Source-attestation-specific fields (``allowed_source_entities``,
165# attestation-mode binding) are deferred to §18 per ADR-002 and are NOT
166# accepted here. See spec/EVOLUTION.md § §18 for the deferred surface.
167# ---------------------------------------------------------------------------
169_STATIC_KEY_ALLOWED_PERMISSIONS: frozenset[str] = frozenset(
170 {"read", "write", "instruction:write", "federate", "admin", "audit.read"}
171)
174# ---------------------------------------------------------------------------
175# Endpoints
176# ---------------------------------------------------------------------------
179@router.post("/oidc/exchange", response_model=ExchangeResponse)
180def oidc_exchange(body: ExchangeRequest) -> ExchangeResponse:
181 """Exchange an OIDC id_token for a scoped stigmem API key."""
182 if not settings.oidc_enabled:
183 raise HTTPException(
184 status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
185 detail="OIDC authentication is not enabled on this node",
186 )
188 claims = _verify_id_token(body.id_token)
189 sub: str = claims["sub"]
190 email: str | None = claims.get("email")
191 _check_domain(email)
193 entity_uri = f"oidc:{sub}"
195 # Experimental memory-garden advanced ACL: cap permissions by garden membership
196 # only when the plugin and its explicit OIDC ceiling gate are enabled.
197 requested = {p for p in body.permissions if p in _ALLOWED_PERMISSIONS}
198 if oidc_permission_ceiling_enabled():
199 requested &= _derive_permission_ceiling(entity_uri)
200 if not requested: 200 ↛ 201line 200 didn't jump to line 201 because the condition on line 200 was never true
201 requested = {"read"}
202 permissions = sorted(requested, key=lambda p: _PERM_ORDER.index(p))
204 expires_at = (datetime.now(UTC) + timedelta(hours=settings.oidc_token_ttl_hours)).isoformat()
206 # Rotate: revoke all previous OIDC-issued keys for this sub before minting
207 # a new one. This ensures a single valid session per sub so that IdP
208 # session-termination (logout, account suspension) immediately invalidates
209 # access — offboarding comes for free from the IdP.
210 with db() as conn:
211 conn.execute("DELETE FROM api_keys WHERE oidc_sub = ?", (sub,))
213 raw_key = create_api_key(
214 entity_uri=entity_uri,
215 permissions=permissions,
216 description=f"OIDC session for {email or sub}",
217 expires_at=expires_at,
218 oidc_sub=sub,
219 )
221 logger.info("OIDC exchange: sub=%s entity_uri=%s perms=%s", sub, entity_uri, permissions)
222 return ExchangeResponse(
223 api_key=raw_key,
224 entity_uri=entity_uri,
225 permissions=permissions,
226 expires_at=expires_at,
227 )
230@router.post(
231 "/keys",
232 response_model=RegisterKeyResponse,
233 status_code=status.HTTP_201_CREATED,
234 summary="Register a caller-provided static API key (admin only).",
235)
236def register_static_key(
237 body: RegisterKeyRequest,
238 identity: Annotated[Identity, Depends(resolve_identity)],
239) -> RegisterKeyResponse:
240 """Register a caller-provided raw API key.
242 Mirrors the bootstrap CLI's posture: the caller supplies the raw key
243 material; the node hashes and stores it. The endpoint requires the
244 caller to hold the ``admin`` capability — typically the bootstrap
245 key, or a previously-minted admin-scoped key.
247 The response NEVER echoes the raw key; the caller already has it.
248 """
249 if not identity.is_admin():
250 raise HTTPException(
251 status_code=status.HTTP_403_FORBIDDEN,
252 detail="admin permission required to mint API keys",
253 )
255 # Validate the permission set. Reject unknown vocabulary up front so a
256 # typo (`"writes"`) doesn't silently create an unintentionally-scoped key.
257 requested_perms = set(body.permissions)
258 invalid = requested_perms - _STATIC_KEY_ALLOWED_PERMISSIONS
259 if invalid:
260 raise HTTPException(
261 status_code=status.HTTP_400_BAD_REQUEST,
262 detail=(
263 f"unknown permissions: {sorted(invalid)}; "
264 f"allowed: {sorted(_STATIC_KEY_ALLOWED_PERMISSIONS)}"
265 ),
266 )
267 if not requested_perms:
268 raise HTTPException(
269 status_code=status.HTTP_400_BAD_REQUEST,
270 detail="permissions list must be non-empty",
271 )
273 # Refuse duplicate raw credentials before hashing. Argon2id uses a fresh
274 # salt for each hash, so duplicate detection must verify existing rows
275 # rather than relying on the key_hash unique constraint.
276 if find_api_key_id_by_raw_key(body.raw_key) is not None:
277 raise HTTPException(
278 status_code=status.HTTP_409_CONFLICT,
279 detail="raw_key already exists; generate a new key value",
280 )
282 target_tenant = body.tenant_id or identity.tenant_id
284 # Persist via the existing helper. Caller-provided raw_key, never
285 # auto-generated.
286 permissions_sorted = sorted(requested_perms)
287 # ``registered_id`` is the UUID bookkeeping handle for the new row, NOT
288 # credential material. The raw key value never enters this scope —
289 # ``register_api_key`` hashes it inside the helper. Naming hygiene
290 # here keeps CodeQL's ``py/clear-text-logging-sensitive-data`` name
291 # heuristic (which matches any variable containing ``key``) from
292 # false-flagging the audit log below. Same precedent as PR #106.
293 try:
294 registered_id = register_api_key(
295 raw_key=body.raw_key,
296 entity_uri=body.entity_uri,
297 permissions=permissions_sorted,
298 description=body.description,
299 expires_at=body.expires_at,
300 tenant_id=target_tenant,
301 )
302 except TenantIdError as exc:
303 raise HTTPException(
304 status_code=status.HTTP_400_BAD_REQUEST,
305 detail=str(exc),
306 ) from exc
307 except ValueError as exc:
308 if "max age" in str(exc): 308 ↛ 313line 308 didn't jump to line 313 because the condition on line 308 was always true
309 raise HTTPException(
310 status_code=status.HTTP_400_BAD_REQUEST,
311 detail=str(exc),
312 ) from exc
313 raise HTTPException(
314 status_code=status.HTTP_409_CONFLICT,
315 detail="raw_key already exists; generate a new key value",
316 ) from exc
318 # Read back canonical values so audit and response use normalized tenant ID.
319 with db() as conn:
320 row = conn.execute(
321 "SELECT created_at, expires_at, tenant_id FROM api_keys WHERE id = ?",
322 (registered_id,),
323 ).fetchone()
324 normalized_tenant = row["tenant_id"] or "default"
326 # Audit: spec §22.3.1 maps lifecycle ops on admin surfaces to
327 # ``admin_action``. Detail captures the new key's identity and the
328 # caller's identity for accountability.
329 audit_emit(
330 "admin_action",
331 entity_uri=identity.entity_uri,
332 tenant_id=normalized_tenant,
333 detail={
334 "action": "api_key_register",
335 "new_key_id": registered_id,
336 "target_entity_uri": body.entity_uri,
337 "permissions": permissions_sorted,
338 "has_expiry": body.expires_at is not None,
339 },
340 )
342 # Deliberately no ``logger.info`` here. The ``audit_emit`` above is
343 # the authoritative record (event_type=admin_action), and a structured
344 # log line is operator-grep convenience at best. CodeQL's
345 # ``py/clear-text-logging-sensitive-data`` taints any value that
346 # transitively flows from the request body's ``raw_key`` field —
347 # including the UUID returned by ``register_api_key`` (which takes
348 # ``raw_key`` as an argument). No combination of message wording
349 # or local-variable substitution clears the taint while still
350 # logging something useful. Operators query the audit log instead:
351 #
352 # SELECT * FROM fact_audit_log
353 # WHERE event_type='admin_action'
354 # AND detail LIKE '%api_key_register%'
355 # ORDER BY ts DESC;
356 #
357 # Same family as Pattern 4 / Pattern 12 in the lessons file —
358 # design-pivot away from the heuristic rather than fight it.
360 return RegisterKeyResponse(
361 id=registered_id,
362 entity_uri=body.entity_uri,
363 permissions=permissions_sorted,
364 description=body.description,
365 created_at=row["created_at"],
366 expires_at=row["expires_at"],
367 tenant_id=normalized_tenant,
368 )
371@router.get("/keys", response_model=list[KeyInfo])
372def list_keys(identity: Annotated[Identity, Depends(resolve_identity)]) -> list[KeyInfo]:
373 """List all non-expired API keys belonging to the caller's entity_uri."""
374 with db() as conn:
375 rows = conn.execute(
376 """SELECT id, entity_uri, permissions, description, created_at, expires_at, oidc_sub
377 FROM api_keys
378 WHERE entity_uri = ?
379 AND (expires_at IS NULL OR expires_at > ?)
380 ORDER BY created_at DESC""",
381 (identity.entity_uri, datetime.now(UTC).isoformat()),
382 ).fetchall()
383 return [
384 KeyInfo(
385 id=r["id"],
386 entity_uri=r["entity_uri"],
387 permissions=json.loads(r["permissions"]),
388 description=r["description"],
389 created_at=r["created_at"],
390 expires_at=r["expires_at"],
391 oidc_sub=r["oidc_sub"],
392 )
393 for r in rows
394 ]
397@router.get("/keys/expiring-soon", response_model=list[ExpiringKeyInfo])
398def list_expiring_keys(
399 identity: Annotated[Identity, Depends(resolve_identity)],
400 within_days: Annotated[
401 int,
402 Query(
403 ge=1,
404 le=365,
405 description="Return active keys expiring within this many days.",
406 ),
407 ] = settings.api_key_expiring_soon_days,
408) -> list[ExpiringKeyInfo]:
409 """List active API keys approaching expiry (admin only)."""
410 if not identity.is_admin():
411 raise HTTPException(
412 status_code=status.HTTP_403_FORBIDDEN,
413 detail="admin permission required to list expiring API keys",
414 )
416 now = datetime.now(UTC)
417 cutoff = now + timedelta(days=within_days)
418 with db() as conn:
419 rows = conn.execute(
420 """SELECT id, entity_uri, permissions, description, created_at,
421 expires_at, oidc_sub, tenant_id
422 FROM api_keys
423 WHERE expires_at IS NOT NULL
424 AND expires_at > ?
425 AND expires_at <= ?
426 ORDER BY expires_at ASC, created_at DESC""",
427 (now.isoformat(), cutoff.isoformat()),
428 ).fetchall()
430 result: list[ExpiringKeyInfo] = []
431 for r in rows:
432 expires_at = datetime.fromisoformat(r["expires_at"].replace("Z", "+00:00"))
433 if expires_at.tzinfo is None: 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true
434 expires_at = expires_at.replace(tzinfo=UTC)
435 days_remaining = (expires_at.astimezone(UTC) - now).total_seconds() / 86_400
436 result.append(
437 ExpiringKeyInfo(
438 id=r["id"],
439 entity_uri=r["entity_uri"],
440 permissions=json.loads(r["permissions"]),
441 description=r["description"],
442 created_at=r["created_at"],
443 expires_at=r["expires_at"],
444 oidc_sub=r["oidc_sub"],
445 tenant_id=r["tenant_id"] or "default",
446 days_remaining=max(days_remaining, 0.0),
447 )
448 )
449 return result
452@router.delete("/keys/{key_id}", status_code=204)
453def revoke_key(
454 key_id: str,
455 identity: Annotated[Identity, Depends(resolve_identity)],
456) -> None:
457 """Revoke a specific API key. Callers may only revoke their own keys."""
458 with db() as conn:
459 row = conn.execute("SELECT entity_uri FROM api_keys WHERE id = ?", (key_id,)).fetchone()
460 if row is None: 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true
461 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Key not found")
462 if row["entity_uri"] != identity.entity_uri:
463 raise HTTPException(
464 status_code=status.HTTP_403_FORBIDDEN,
465 detail="Cannot revoke another entity's key",
466 )
467 conn.execute("DELETE FROM api_keys WHERE id = ?", (key_id,))
468 logger.info("Key revoked: id=%s by entity=%s", key_id, identity.entity_uri)