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

1"""Track B / B3 + C2 — OIDC → scoped API-key exchange bridge. 

2 

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 

7 

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"]. 

12 

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""" 

19 

20from __future__ import annotations 

21 

22import json 

23import logging 

24import time 

25from datetime import UTC, datetime, timedelta 

26from typing import Annotated, Any 

27 

28import httpx 

29import jwt 

30from fastapi import APIRouter, Depends, HTTPException, Query, status 

31 

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 

53 

54logger = logging.getLogger("stigmem.auth") 

55router = APIRouter(prefix="/v1/auth", tags=["auth"]) 

56 

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 

62 

63 

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] 

69 

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 

84 

85 client = jwt.PyJWKClient(jwks_uri) 

86 _JWKS_CACHE[issuer_url] = (client, now) 

87 return client 

88 

89 

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 

118 

119 

120def _check_domain(email: str | None, email_verified: bool) -> None: 

121 """Enforce oidc_allowed_domains if configured. 

122 

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. 

128 

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 ) 

153 

154 

155# --------------------------------------------------------------------------- 

156# Request / response models 

157# --------------------------------------------------------------------------- 

158 

159_ALLOWED_PERMISSIONS = {"read", "write"} 

160_PERM_ORDER = ["read", "write"] 

161 

162 

163def _derive_permission_ceiling(entity_uri: str) -> set[str]: 

164 """Return the max permissions the entity is entitled to via garden membership. 

165 

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"} 

178 

179 

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# --------------------------------------------------------------------------- 

188 

189_STATIC_KEY_ALLOWED_PERMISSIONS: frozenset[str] = frozenset( 

190 {"read", "write", "instruction:write", "federate", "admin", "audit.read"} 

191) 

192 

193 

194# --------------------------------------------------------------------------- 

195# Endpoints 

196# --------------------------------------------------------------------------- 

197 

198 

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 ) 

207 

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) 

214 

215 entity_uri = f"oidc:{sub}" 

216 

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)) 

225 

226 expires_at = (datetime.now(UTC) + timedelta(hours=settings.oidc_token_ttl_hours)).isoformat() 

227 

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,)) 

234 

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 ) 

242 

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 ) 

250 

251 

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. 

263 

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. 

268 

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 ) 

276 

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 ) 

294 

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 ) 

303 

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 ) 

314 

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 

348 

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" 

356 

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 ) 

372 

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. 

390 

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 ) 

400 

401 

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 ] 

426 

427 

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 ) 

446 

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() 

460 

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 

481 

482 

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)