Coverage for node / src / stigmem_node / routes / auth.py: 95%
153 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +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, email_verified: bool) -> None:
121 """Enforce oidc_allowed_domains if configured.
123 The allowlist may only be satisfied by a *verified* email. An unverified
124 or absent email is treated as no email: when no allowlist is configured the
125 node does not enforce one (``email_verified`` is irrelevant, unchanged
126 behavior), but when an allowlist IS configured an unverified/absent email
127 cannot satisfy it and the exchange is rejected.
129 ``email_verified`` is the strict boolean ``claims.get("email_verified") is
130 True`` evaluated by the caller — some IdPs emit the string ``"true"``; only
131 the OIDC-spec boolean ``True`` counts as verified.
132 """
133 if not settings.oidc_allowed_domains:
134 return
135 allowed = {d.strip().lower() for d in settings.oidc_allowed_domains.split(",") if d.strip()}
136 if not allowed: 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true
137 return
138 # An allowlist is configured: only a verified email can satisfy it. An
139 # unverified or absent email is rejected — an attacker who tricks an IdP
140 # into issuing email=victim@alloweddomain.com with email_verified=false (or
141 # omitted) must not pass the allowlist (F-SAUTH1).
142 if not email or not email_verified:
143 raise HTTPException(
144 status_code=status.HTTP_403_FORBIDDEN,
145 detail="A verified email in an allowed domain is required",
146 )
147 domain = email.split("@", 1)[-1].lower()
148 if domain not in allowed:
149 raise HTTPException(
150 status_code=status.HTTP_403_FORBIDDEN,
151 detail=f"Email domain '{domain}' is not permitted",
152 )
155# ---------------------------------------------------------------------------
156# Request / response models
157# ---------------------------------------------------------------------------
159_ALLOWED_PERMISSIONS = {"read", "write"}
160_PERM_ORDER = ["read", "write"]
163def _derive_permission_ceiling(entity_uri: str) -> set[str]:
164 """Return the max permissions the entity is entitled to via garden membership.
166 admin/writer role in any garden → {"read","write"}
167 reader role only, or no membership → {"read"}
168 """
169 with db() as conn:
170 rows = conn.execute(
171 "SELECT role FROM garden_members WHERE entity_uri = ?",
172 (entity_uri,),
173 ).fetchall()
174 roles = {r["role"] for r in rows}
175 if "admin" in roles or "writer" in roles:
176 return {"read", "write"}
177 return {"read"}
180# ---------------------------------------------------------------------------
181# Static key registration (POST /v1/auth/keys) — issue #135
182#
183# Permission vocabulary kept narrow to the §3.5 set the auth module already
184# defines. Source-attestation-specific fields (``allowed_source_entities``,
185# attestation-mode binding) are deferred to §18 per ADR-002 and are NOT
186# accepted here. See spec/EVOLUTION.md § §18 for the deferred surface.
187# ---------------------------------------------------------------------------
189_STATIC_KEY_ALLOWED_PERMISSIONS: frozenset[str] = frozenset(
190 {"read", "write", "instruction:write", "federate", "admin", "audit.read"}
191)
194# ---------------------------------------------------------------------------
195# Endpoints
196# ---------------------------------------------------------------------------
199@router.post("/oidc/exchange", response_model=ExchangeResponse)
200def oidc_exchange(body: ExchangeRequest) -> ExchangeResponse:
201 """Exchange an OIDC id_token for a scoped stigmem API key."""
202 if not settings.oidc_enabled:
203 raise HTTPException(
204 status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
205 detail="OIDC authentication is not enabled on this node",
206 )
208 claims = _verify_id_token(body.id_token)
209 sub: str = claims["sub"]
210 email: str | None = claims.get("email")
211 # Strict boolean identity: some IdPs send the string "true"; only the
212 # OIDC-spec boolean True counts as verified.
213 _check_domain(email, claims.get("email_verified") is True)
215 entity_uri = f"oidc:{sub}"
217 # Experimental memory-garden advanced ACL: cap permissions by garden membership
218 # only when the plugin and its explicit OIDC ceiling gate are enabled.
219 requested = {p for p in body.permissions if p in _ALLOWED_PERMISSIONS}
220 if oidc_permission_ceiling_enabled():
221 requested &= _derive_permission_ceiling(entity_uri)
222 if not requested: 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true
223 requested = {"read"}
224 permissions = sorted(requested, key=lambda p: _PERM_ORDER.index(p))
226 expires_at = (datetime.now(UTC) + timedelta(hours=settings.oidc_token_ttl_hours)).isoformat()
228 # Rotate: revoke all previous OIDC-issued keys for this sub before minting
229 # a new one. This ensures a single valid session per sub so that IdP
230 # session-termination (logout, account suspension) immediately invalidates
231 # access — offboarding comes for free from the IdP.
232 with db() as conn:
233 conn.execute("DELETE FROM api_keys WHERE oidc_sub = ?", (sub,))
235 raw_key = create_api_key(
236 entity_uri=entity_uri,
237 permissions=permissions,
238 description=f"OIDC session for {email or sub}",
239 expires_at=expires_at,
240 oidc_sub=sub,
241 )
243 logger.info("OIDC exchange: sub=%s entity_uri=%s perms=%s", sub, entity_uri, permissions)
244 return ExchangeResponse(
245 api_key=raw_key,
246 entity_uri=entity_uri,
247 permissions=permissions,
248 expires_at=expires_at,
249 )
252@router.post(
253 "/keys",
254 response_model=RegisterKeyResponse,
255 status_code=status.HTTP_201_CREATED,
256 summary="Register a caller-provided static API key (admin only).",
257)
258def register_static_key(
259 body: RegisterKeyRequest,
260 identity: Annotated[Identity, Depends(resolve_identity)],
261) -> RegisterKeyResponse:
262 """Register a caller-provided raw API key.
264 Mirrors the bootstrap CLI's posture: the caller supplies the raw key
265 material; the node hashes and stores it. The endpoint requires the
266 caller to hold the ``admin`` capability — typically the bootstrap
267 key, or a previously-minted admin-scoped key.
269 The response NEVER echoes the raw key; the caller already has it.
270 """
271 if not identity.is_admin():
272 raise HTTPException(
273 status_code=status.HTTP_403_FORBIDDEN,
274 detail="admin permission required to mint API keys",
275 )
277 # Validate the permission set. Reject unknown vocabulary up front so a
278 # typo (`"writes"`) doesn't silently create an unintentionally-scoped key.
279 requested_perms = set(body.permissions)
280 invalid = requested_perms - _STATIC_KEY_ALLOWED_PERMISSIONS
281 if invalid:
282 raise HTTPException(
283 status_code=status.HTTP_400_BAD_REQUEST,
284 detail=(
285 f"unknown permissions: {sorted(invalid)}; "
286 f"allowed: {sorted(_STATIC_KEY_ALLOWED_PERMISSIONS)}"
287 ),
288 )
289 if not requested_perms:
290 raise HTTPException(
291 status_code=status.HTTP_400_BAD_REQUEST,
292 detail="permissions list must be non-empty",
293 )
295 # Refuse duplicate raw credentials before hashing. Argon2id uses a fresh
296 # salt for each hash, so duplicate detection must verify existing rows
297 # rather than relying on the key_hash unique constraint.
298 if find_api_key_id_by_raw_key(body.raw_key) is not None:
299 raise HTTPException(
300 status_code=status.HTTP_409_CONFLICT,
301 detail="raw_key already exists; generate a new key value",
302 )
304 # Minting a key in a tenant other than the caller's own is a cross-tenant
305 # privilege escalation (audit M2): a tenant-scoped admin could otherwise mint
306 # an admin key in any other tenant. Require the elevated cross-cutting
307 # admin:federation capability to cross the tenant boundary; default same-tenant.
308 target_tenant = body.tenant_id or identity.tenant_id
309 if target_tenant != identity.tenant_id and not identity.can_admin_federation():
310 raise HTTPException(
311 status_code=status.HTTP_403_FORBIDDEN,
312 detail="cannot mint keys in a tenant other than your own",
313 )
315 # Persist via the existing helper. Caller-provided raw_key, never
316 # auto-generated.
317 permissions_sorted = sorted(requested_perms)
318 # ``registered_id`` is the UUID bookkeeping handle for the new row, NOT
319 # credential material. The raw key value never enters this scope —
320 # ``register_api_key`` hashes it inside the helper. Naming hygiene
321 # here keeps CodeQL's ``py/clear-text-logging-sensitive-data`` name
322 # heuristic (which matches any variable containing ``key``) from
323 # false-flagging the audit log below. Same precedent as PR #106.
324 try:
325 registered_id = register_api_key(
326 raw_key=body.raw_key,
327 entity_uri=body.entity_uri,
328 permissions=permissions_sorted,
329 description=body.description,
330 expires_at=body.expires_at,
331 tenant_id=target_tenant,
332 )
333 except TenantIdError as exc:
334 raise HTTPException(
335 status_code=status.HTTP_400_BAD_REQUEST,
336 detail=str(exc),
337 ) from exc
338 except ValueError as exc:
339 if "max age" in str(exc): 339 ↛ 344line 339 didn't jump to line 344 because the condition on line 339 was always true
340 raise HTTPException(
341 status_code=status.HTTP_400_BAD_REQUEST,
342 detail=str(exc),
343 ) from exc
344 raise HTTPException(
345 status_code=status.HTTP_409_CONFLICT,
346 detail="raw_key already exists; generate a new key value",
347 ) from exc
349 # Read back canonical values so audit and response use normalized tenant ID.
350 with db() as conn:
351 row = conn.execute(
352 "SELECT created_at, expires_at, tenant_id FROM api_keys WHERE id = ?",
353 (registered_id,),
354 ).fetchone()
355 normalized_tenant = row["tenant_id"] or "default"
357 # Audit: spec §22.3.1 maps lifecycle ops on admin surfaces to
358 # ``admin_action``. Detail captures the new key's identity and the
359 # caller's identity for accountability.
360 audit_emit(
361 "admin_action",
362 entity_uri=identity.entity_uri,
363 tenant_id=normalized_tenant,
364 detail={
365 "action": "api_key_register",
366 "new_key_id": registered_id,
367 "target_entity_uri": body.entity_uri,
368 "permissions": permissions_sorted,
369 "has_expiry": body.expires_at is not None,
370 },
371 )
373 # Deliberately no ``logger.info`` here. The ``audit_emit`` above is
374 # the authoritative record (event_type=admin_action), and a structured
375 # log line is operator-grep convenience at best. CodeQL's
376 # ``py/clear-text-logging-sensitive-data`` taints any value that
377 # transitively flows from the request body's ``raw_key`` field —
378 # including the UUID returned by ``register_api_key`` (which takes
379 # ``raw_key`` as an argument). No combination of message wording
380 # or local-variable substitution clears the taint while still
381 # logging something useful. Operators query the audit log instead:
382 #
383 # SELECT * FROM fact_audit_log
384 # WHERE event_type='admin_action'
385 # AND detail LIKE '%api_key_register%'
386 # ORDER BY ts DESC;
387 #
388 # Same family as Pattern 4 / Pattern 12 in the lessons file —
389 # design-pivot away from the heuristic rather than fight it.
391 return RegisterKeyResponse(
392 id=registered_id,
393 entity_uri=body.entity_uri,
394 permissions=permissions_sorted,
395 description=body.description,
396 created_at=row["created_at"],
397 expires_at=row["expires_at"],
398 tenant_id=normalized_tenant,
399 )
402@router.get("/keys", response_model=list[KeyInfo])
403def list_keys(identity: Annotated[Identity, Depends(resolve_identity)]) -> list[KeyInfo]:
404 """List all non-expired API keys belonging to the caller's entity_uri."""
405 with db() as conn:
406 rows = conn.execute(
407 """SELECT id, entity_uri, permissions, description, created_at, expires_at, oidc_sub
408 FROM api_keys
409 WHERE entity_uri = ?
410 AND (expires_at IS NULL OR expires_at > ?)
411 ORDER BY created_at DESC""",
412 (identity.entity_uri, datetime.now(UTC).isoformat()),
413 ).fetchall()
414 return [
415 KeyInfo(
416 id=r["id"],
417 entity_uri=r["entity_uri"],
418 permissions=json.loads(r["permissions"]),
419 description=r["description"],
420 created_at=r["created_at"],
421 expires_at=r["expires_at"],
422 oidc_sub=r["oidc_sub"],
423 )
424 for r in rows
425 ]
428@router.get("/keys/expiring-soon", response_model=list[ExpiringKeyInfo])
429def list_expiring_keys(
430 identity: Annotated[Identity, Depends(resolve_identity)],
431 within_days: Annotated[
432 int,
433 Query(
434 ge=1,
435 le=365,
436 description="Return active keys expiring within this many days.",
437 ),
438 ] = settings.api_key_expiring_soon_days,
439) -> list[ExpiringKeyInfo]:
440 """List active API keys approaching expiry (admin only)."""
441 if not identity.is_admin():
442 raise HTTPException(
443 status_code=status.HTTP_403_FORBIDDEN,
444 detail="admin permission required to list expiring API keys",
445 )
447 now = datetime.now(UTC)
448 cutoff = now + timedelta(days=within_days)
449 with db() as conn:
450 rows = conn.execute(
451 """SELECT id, entity_uri, permissions, description, created_at,
452 expires_at, oidc_sub, tenant_id
453 FROM api_keys
454 WHERE expires_at IS NOT NULL
455 AND expires_at > ?
456 AND expires_at <= ?
457 ORDER BY expires_at ASC, created_at DESC""",
458 (now.isoformat(), cutoff.isoformat()),
459 ).fetchall()
461 result: list[ExpiringKeyInfo] = []
462 for r in rows:
463 expires_at = datetime.fromisoformat(r["expires_at"].replace("Z", "+00:00"))
464 if expires_at.tzinfo is None: 464 ↛ 465line 464 didn't jump to line 465 because the condition on line 464 was never true
465 expires_at = expires_at.replace(tzinfo=UTC)
466 days_remaining = (expires_at.astimezone(UTC) - now).total_seconds() / 86_400
467 result.append(
468 ExpiringKeyInfo(
469 id=r["id"],
470 entity_uri=r["entity_uri"],
471 permissions=json.loads(r["permissions"]),
472 description=r["description"],
473 created_at=r["created_at"],
474 expires_at=r["expires_at"],
475 oidc_sub=r["oidc_sub"],
476 tenant_id=r["tenant_id"] or "default",
477 days_remaining=max(days_remaining, 0.0),
478 )
479 )
480 return result
483@router.delete("/keys/{key_id}", status_code=204)
484def revoke_key(
485 key_id: str,
486 identity: Annotated[Identity, Depends(resolve_identity)],
487) -> None:
488 """Revoke a specific API key. Callers may only revoke their own keys."""
489 with db() as conn:
490 row = conn.execute("SELECT entity_uri FROM api_keys WHERE id = ?", (key_id,)).fetchone()
491 if row is None: 491 ↛ 492line 491 didn't jump to line 492 because the condition on line 491 was never true
492 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Key not found")
493 if row["entity_uri"] != identity.entity_uri:
494 raise HTTPException(
495 status_code=status.HTTP_403_FORBIDDEN,
496 detail="Cannot revoke another entity's key",
497 )
498 conn.execute("DELETE FROM api_keys WHERE id = ?", (key_id,))
499 logger.info("Key revoked: id=%s by entity=%s", key_id, identity.entity_uri)