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

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

133 

134 

135# --------------------------------------------------------------------------- 

136# Request / response models 

137# --------------------------------------------------------------------------- 

138 

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

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

141 

142 

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

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

145 

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

158 

159 

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

168 

169_STATIC_KEY_ALLOWED_PERMISSIONS: frozenset[str] = frozenset( 

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

171) 

172 

173 

174# --------------------------------------------------------------------------- 

175# Endpoints 

176# --------------------------------------------------------------------------- 

177 

178 

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 ) 

187 

188 claims = _verify_id_token(body.id_token) 

189 sub: str = claims["sub"] 

190 email: str | None = claims.get("email") 

191 _check_domain(email) 

192 

193 entity_uri = f"oidc:{sub}" 

194 

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

203 

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

205 

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

212 

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 ) 

220 

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 ) 

228 

229 

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. 

241 

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. 

246 

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 ) 

254 

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 ) 

272 

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 ) 

281 

282 target_tenant = body.tenant_id or identity.tenant_id 

283 

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 

317 

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" 

325 

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 ) 

341 

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. 

359 

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 ) 

369 

370 

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 ] 

395 

396 

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 ) 

415 

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

429 

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 

450 

451 

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)