Coverage for node / src / stigmem_node / routes / identity.py: 80%

185 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-18 05:34 +0000

1"""Org-identity API routes — spec §19.1–§19.3, §19.5. 

2 

3Routes: 

4 PUT /v1/federation/manifest — publish/update org manifest 

5 GET /v1/federation/manifest/{entity_uri_encoded} — resolve manifest 

6 POST /v1/federation/capability-tokens — issue capability token 

7 POST /v1/federation/capability-tokens/verify — verify a capability token (CLI + peers) 

8 POST /v1/federation/capability-tokens/{token_id}/revoke — revoke token 

9 POST /v1/gardens/{id}/promote — quarantine moderation (garden-scoped) 

10 POST /v1/gardens/{id}/reject — quarantine moderation (garden-scoped) 

11 

12Security requirements enforced here: 

13 C1: token subject MUST appear in issuer's manifest entities list. 

14 H1: expired manifest → reject token issuance (handled via get_peer_manifest). 

15 H2: TL unavailable in strict mode → HTTP 503 (not silent fallback). 

16 Rate-limit manifest PUT: ≤ 10 per entity_uri per hour. 

17 Nonce: 64-char lowercase hex (secrets.token_hex(32)). 

18 Cleanup: expired tokens pruned opportunistically on issuance. 

19""" 

20 

21from __future__ import annotations 

22 

23import json 

24import logging 

25import secrets 

26import time 

27import uuid 

28from datetime import UTC, datetime 

29from typing import Annotated, Any 

30from urllib.parse import unquote 

31 

32from fastapi import APIRouter, Depends, HTTPException, status 

33 

34from ..auth import Identity, resolve_identity 

35from ..db import db 

36from ..identity.capability import ( 

37 CapabilityTokenError, 

38 load_node_private_key, 

39 sign_revocation_event, 

40 sign_token, 

41 verify_token, 

42) 

43from ..identity.manifest import ( 

44 ManifestError, 

45 manifest_from_dict, 

46 manifest_to_dict, 

47 verify_manifest, 

48) 

49from ..identity.transparency_log import TransparencyLogUnavailable, make_transparency_log 

50from ..identity.trust_store import ( 

51 cleanup_expired_tokens, 

52 get_peer_manifest, 

53 store_peer_manifest, 

54) 

55from ..settings import settings 

56 

57router = APIRouter(tags=["identity"]) 

58logger = logging.getLogger("stigmem.routes.identity") 

59 

60# --------------------------------------------------------------------------- 

61# Manifest-PUT rate limiter: { entity_uri: [epoch_s, ...] } (in-process) 

62# --------------------------------------------------------------------------- 

63 

64_MANIFEST_SUBMIT_WINDOW_S = 3600 

65_MANIFEST_SUBMIT_LIMIT = 10 

66_manifest_submit_log: dict[str, list[float]] = {} 

67 

68 

69def _check_manifest_rate_limit(entity_uri: str) -> None: 

70 now = time.monotonic() 

71 window_start = now - _MANIFEST_SUBMIT_WINDOW_S 

72 timestamps = [t for t in _manifest_submit_log.get(entity_uri, []) if t > window_start] 

73 if len(timestamps) >= _MANIFEST_SUBMIT_LIMIT: 

74 raise HTTPException( 

75 status_code=status.HTTP_429_TOO_MANY_REQUESTS, 

76 detail=f"manifest PUT rate limit: max {_MANIFEST_SUBMIT_LIMIT} per hour per entity_uri", 

77 ) 

78 timestamps.append(now) 

79 _manifest_submit_log[entity_uri] = timestamps 

80 

81 

82# --------------------------------------------------------------------------- 

83# PUT /v1/federation/manifest 

84# --------------------------------------------------------------------------- 

85 

86 

87@router.put("/v1/federation/manifest", status_code=status.HTTP_200_OK) 

88async def put_manifest( 

89 body: dict[str, Any], 

90 identity: Annotated[Identity, Depends(resolve_identity)], 

91) -> dict[str, Any]: 

92 """Publish or update an org manifest. 

93 

94 Rate-limited to 10 submissions per entity_uri per hour. 

95 rotation_events arrays with > 100 entries are rejected. 

96 TL submission is attempted; in strict mode TL failure → 503. 

97 """ 

98 if not identity.can_write(): 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true

99 raise HTTPException(status_code=403, detail="write permission required") 

100 

101 entity_uri = body.get("entity_uri", "") 

102 if not entity_uri: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true

103 raise HTTPException(status_code=422, detail="entity_uri is required") 

104 

105 _check_manifest_rate_limit(entity_uri) 

106 

107 rotation_events = body.get("rotation_events", []) 

108 if len(rotation_events) > 100: 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true

109 raise HTTPException( 

110 status_code=422, 

111 detail="rotation_events must not exceed 100 entries", 

112 ) 

113 

114 try: 

115 manifest = manifest_from_dict(body) 

116 except (KeyError, ValueError) as exc: 

117 raise HTTPException(status_code=422, detail=f"invalid manifest: {exc}") from exc 

118 

119 try: 

120 verify_manifest(manifest, trust_mode=settings.trust_mode) 

121 except ManifestError as exc: 

122 raise HTTPException(status_code=422, detail=str(exc)) from exc 

123 

124 # Phase 2a — key unification: a node may only publish its OWN manifest, signed by its 

125 # federation/peer-token key. Reject a manifest whose public_key diverges from this node's 

126 # federation pubkey (prevents an independent manifest key, the laundering precondition). 

127 from ..federation.peer_token import get_local_pubkey 

128 

129 if manifest.public_key != get_local_pubkey(): 

130 raise HTTPException( 

131 status_code=422, 

132 detail=( 

133 "manifest public_key must equal this node's federation key " 

134 "(Phase 2a unification)" 

135 ), 

136 ) 

137 

138 # Attempt transparency-log submission 

139 tl = make_transparency_log() 

140 log_entry = None 

141 tl_error: str | None = None 

142 

143 try: 

144 log_entry = tl.submit(manifest_to_dict(manifest)) 

145 except TransparencyLogUnavailable as exc: 

146 tl_error = str(exc) 

147 if settings.trust_mode == "strict": 

148 raise HTTPException( 

149 status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 

150 detail=f"transparency log unavailable (strict mode rejects): {exc}", 

151 ) from exc 

152 logger.warning("TL unavailable during manifest PUT (warn mode): %s", exc) 

153 

154 try: 

155 store_peer_manifest(entity_uri, manifest, log_entry, trust_mode=settings.trust_mode) 

156 except ManifestError as exc: 

157 raise HTTPException(status_code=409, detail=str(exc)) from exc 

158 

159 result: dict[str, Any] = { 

160 "entity_uri": entity_uri, 

161 "key_id": manifest.key_id, 

162 "issued_at": manifest.issued_at, 

163 "expires_at": manifest.expires_at, 

164 } 

165 if log_entry is not None: 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true

166 result["log_entry"] = { 

167 "log_id": log_entry.log_id, 

168 "log_index": log_entry.log_index, 

169 "leaf_hash": log_entry.leaf_hash, 

170 "integrated_time": log_entry.integrated_time, 

171 } 

172 if tl_error is not None: 172 ↛ 174line 172 didn't jump to line 174 because the condition on line 172 was always true

173 result["tl_warning"] = tl_error 

174 return result 

175 

176 

177# --------------------------------------------------------------------------- 

178# GET /v1/federation/manifest/{entity_uri_encoded} 

179# --------------------------------------------------------------------------- 

180 

181 

182@router.get("/v1/federation/manifest/{entity_uri_encoded:path}") 

183def get_manifest( 

184 entity_uri_encoded: str, 

185 identity: Annotated[Identity, Depends(resolve_identity)], 

186) -> dict[str, Any]: 

187 """Resolve a peer manifest by entity_uri (URL-encoded or raw path).""" 

188 if not identity.can_read(): 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true

189 raise HTTPException(status_code=403, detail="read permission required") 

190 

191 entity_uri = unquote(entity_uri_encoded) 

192 manifest = get_peer_manifest( 

193 entity_uri, refresh_if_expired=True, trust_mode=settings.trust_mode 

194 ) 

195 if manifest is None: 

196 raise HTTPException(status_code=404, detail="manifest not found or expired") 

197 

198 with db() as conn: 

199 row = conn.execute( 

200 "SELECT log_entry_json FROM federation_manifests WHERE entity_uri = ?", 

201 (entity_uri,), 

202 ).fetchone() 

203 

204 result = manifest_to_dict(manifest) 

205 if row and row["log_entry_json"]: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true

206 result["log_entry"] = json.loads(row["log_entry_json"]) 

207 return result 

208 

209 

210# --------------------------------------------------------------------------- 

211# POST /v1/federation/capability-tokens 

212# --------------------------------------------------------------------------- 

213 

214 

215@router.post( 

216 "/v1/federation/capability-tokens", 

217 status_code=status.HTTP_201_CREATED, 

218) 

219def issue_capability_token( 

220 body: dict[str, Any], 

221 identity: Annotated[Identity, Depends(resolve_identity)], 

222) -> dict[str, Any]: 

223 """Issue a capability token. 

224 

225 C1: token subject must appear in the issuer's manifest entities list. 

226 H1: issuer manifest must not be expired. 

227 Nonce: 64-char lowercase hex enforced by DB CHECK constraint. 

228 """ 

229 if not identity.can_write(): 229 ↛ 230line 229 didn't jump to line 230 because the condition on line 229 was never true

230 raise HTTPException(status_code=403, detail="write permission required") 

231 

232 issuer = body.get("issuer", "") 

233 subject = body.get("subject", "") 

234 verb = body.get("verb", "") 

235 obj = body.get("object", "") 

236 ttl_seconds: int = int(body.get("ttl_seconds", 3600)) 

237 

238 if not all([issuer, subject, verb, obj]): 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true

239 raise HTTPException( 

240 status_code=422, detail="issuer, subject, verb, and object are required" 

241 ) 

242 

243 # M-SEC-1: enforce 90-day maximum TTL (spec §19.3.2) 

244 _MAX_TOKEN_TTL_SECONDS = 90 * 86400 

245 if ttl_seconds > _MAX_TOKEN_TTL_SECONDS: 

246 raise HTTPException( 

247 status_code=422, 

248 detail=f"ttl_seconds exceeds 90-day maximum ({_MAX_TOKEN_TTL_SECONDS}s)", 

249 ) 

250 

251 # BOLA guard (H-SEC-1): prevent a caller from forging tokens under a different org's identity 

252 if issuer != identity.entity_uri: 

253 raise HTTPException( 

254 status_code=status.HTTP_403_FORBIDDEN, 

255 detail="issuer must match the authenticated caller entity_uri", 

256 ) 

257 

258 # H1: resolve issuer manifest (checks expiry, refreshes if needed) 

259 issuer_manifest = get_peer_manifest( 

260 issuer, refresh_if_expired=True, trust_mode=settings.trust_mode 

261 ) 

262 if issuer_manifest is None: 

263 raise HTTPException( 

264 status_code=422, 

265 detail=f"issuer manifest not found or expired for {issuer!r} " 

266 "(H1: cannot issue tokens from expired manifests)", 

267 ) 

268 

269 # C1: subject must be in issuer's entities list 

270 if subject not in issuer_manifest.entities: 

271 raise HTTPException( 

272 status_code=403, 

273 detail=f"subject {subject!r} is not in issuer {issuer!r} entities list " 

274 "(C1: external-entity delegation not permitted)", 

275 ) 

276 

277 now = datetime.now(UTC) 

278 issued_at = now.isoformat() 

279 expiry = datetime.fromtimestamp(now.timestamp() + ttl_seconds, tz=UTC).isoformat() 

280 token_id = str(uuid.uuid4()) 

281 nonce = secrets.token_hex(32) # 64 lowercase hex chars — satisfies DB CHECK 

282 

283 token_body: dict[str, Any] = { 

284 "token_id": token_id, 

285 "token_version": 1, # nosec B105 — integer version field, not a password 

286 "issuer": issuer, 

287 "subject": subject, 

288 "verb": verb, 

289 "object": obj, 

290 "issued_at": issued_at, 

291 "expiry": expiry, 

292 "nonce": nonce, 

293 } 

294 

295 # Sign token body if node_private_key is configured (C-SEC-1 / spec §19.3.2) 

296 if load_node_private_key() is not None: 

297 token_body["signature"] = sign_token(token_body) 

298 else: 

299 logger.warning( 

300 "STIGMEM_NODE_PRIVATE_KEY not set; issuing unsigned capability token " 

301 "(set the env var to enable spec-compliant signing)" 

302 ) 

303 

304 import canonicaljson 

305 

306 token_json = canonicaljson.encode_canonical_json(token_body).decode() 

307 

308 created_at = now.isoformat() 

309 with db() as conn: 

310 try: 

311 conn.execute( 

312 """INSERT INTO capability_tokens 

313 (id, token_json, issuer, subject, verb, object, 

314 issued_at, expiry, nonce, created_at) 

315 VALUES (?,?,?,?,?,?,?,?,?,?)""", 

316 ( 

317 token_id, 

318 token_json, 

319 issuer, 

320 subject, 

321 verb, 

322 obj, 

323 issued_at, 

324 expiry, 

325 nonce, 

326 created_at, 

327 ), 

328 ) 

329 except Exception as exc: 

330 if "UNIQUE constraint" in str(exc): 

331 raise HTTPException(status_code=409, detail="nonce collision; retry") from exc 

332 raise 

333 

334 # M-SEC-4: audit log entry for token issuance (spec §19.3.2) 

335 conn.execute( 

336 """INSERT INTO fact_audit_log 

337 (id, fact_id, event_type, entity_uri, oidc_sub, source, attested_key_id, detail, ts) 

338 VALUES (?,?,?,?,?,?,?,?,?)""", 

339 ( 

340 str(uuid.uuid4()), 

341 token_id, # token_id as surrogate fact_id 

342 "capability_issued", 

343 issuer, 

344 None, 

345 "system:capability", 

346 None, 

347 json.dumps( 

348 { 

349 "issuer": issuer, 

350 "subject": subject, 

351 "verb": verb, 

352 "object": obj, 

353 "expiry": expiry, 

354 } 

355 ), 

356 created_at, 

357 ), 

358 ) 

359 

360 # Opportunistic cleanup of stale tokens 

361 try: 

362 cleanup_expired_tokens() 

363 except Exception: 

364 logger.exception("Best-effort expired capability token cleanup failed") 

365 

366 return { 

367 "token_id": token_id, 

368 "issuer": issuer, 

369 "subject": subject, 

370 "verb": verb, 

371 "object": obj, 

372 "issued_at": issued_at, 

373 "expiry": expiry, 

374 "nonce": nonce, 

375 "token_json": token_json, 

376 } 

377 

378 

379# --------------------------------------------------------------------------- 

380# POST /v1/federation/capability-tokens/verify 

381# --------------------------------------------------------------------------- 

382# NOTE: this static path MUST be registered before /{token_id}/revoke so that 

383# FastAPI does not swallow "verify" as a {token_id} capture. 

384 

385 

386@router.post( 

387 "/v1/federation/capability-tokens/verify", 

388 status_code=status.HTTP_200_OK, 

389) 

390def verify_capability_token_endpoint( 

391 body: dict[str, Any], 

392 identity: Annotated[Identity, Depends(resolve_identity)], 

393) -> dict[str, Any]: 

394 """Verify a capability token (Spec-06-Capability-Tokens). 

395 

396 Returns {"valid": true} on success, or {"valid": false, "reason": "..."} 

397 when the token fails any verification step (expired, bad sig, revoked, etc.). 

398 HTTP 200 in both cases; 422 only for a missing/malformed request body. 

399 """ 

400 if not identity.can_read(): 400 ↛ 401line 400 didn't jump to line 401 because the condition on line 400 was never true

401 raise HTTPException(status_code=403, detail="read permission required") 

402 

403 token_json = body.get("token_json", "") 

404 if not token_json: 

405 raise HTTPException(status_code=422, detail="token_json is required") 

406 

407 try: 

408 verify_token( 

409 token_json, 

410 lambda uri: get_peer_manifest( 

411 uri, refresh_if_expired=True, trust_mode=settings.trust_mode 

412 ), 

413 trust_mode=settings.trust_mode, 

414 ) 

415 return {"valid": True} 

416 except CapabilityTokenError as exc: 

417 err = str(exc) 

418 if "revoked" in err: 418 ↛ 420line 418 didn't jump to line 420 because the condition on line 418 was always true

419 reason = "token_revoked" 

420 elif "expired" in err: 

421 reason = "token_expired" 

422 else: 

423 reason = "token_invalid" 

424 return {"valid": False, "reason": reason} 

425 

426 

427# --------------------------------------------------------------------------- 

428# POST /v1/federation/capability-tokens/{token_id}/revoke 

429# --------------------------------------------------------------------------- 

430 

431 

432@router.post( 

433 "/v1/federation/capability-tokens/{token_id}/revoke", 

434 status_code=status.HTTP_200_OK, 

435) 

436async def revoke_capability_token( 

437 token_id: str, 

438 body: dict[str, Any], 

439 identity: Annotated[Identity, Depends(resolve_identity)], 

440) -> dict[str, Any]: 

441 """Revoke a capability token and submit a revocation notice to the TL.""" 

442 if not identity.can_write(): 442 ↛ 443line 442 didn't jump to line 443 because the condition on line 442 was never true

443 raise HTTPException(status_code=403, detail="write permission required") 

444 

445 now = datetime.now(UTC).isoformat() 

446 reason = body.get("reason", "") 

447 

448 with db() as conn: 

449 row = conn.execute( 

450 "SELECT id, issuer, subject, revoked_at FROM capability_tokens WHERE id = ?", 

451 (token_id,), 

452 ).fetchone() 

453 

454 if row is None: 

455 raise HTTPException(status_code=404, detail="capability token not found") 

456 if row["revoked_at"] is not None: 456 ↛ 457line 456 didn't jump to line 457 because the condition on line 456 was never true

457 raise HTTPException(status_code=409, detail="token already revoked") 

458 

459 # BOLA guard: only the issuer or subject may revoke their own token. 

460 if row["issuer"] != identity.entity_uri and row["subject"] != identity.entity_uri: 

461 raise HTTPException( 

462 status_code=status.HTTP_403_FORBIDDEN, 

463 detail="not authorized to revoke this token: caller is not the issuer or subject", 

464 ) 

465 

466 revoke_event: dict[str, Any] = { 

467 "token_id": token_id, 

468 "revoked_by": identity.entity_uri, 

469 "revoked_at": now, 

470 "reason": reason, 

471 } 

472 

473 # Sign revocation event (spec §19.3.4 — best-effort when key not configured) 

474 if load_node_private_key() is not None: 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true

475 revoke_event["signature"] = sign_revocation_event(revoke_event) 

476 

477 # Submit revocation to TL (best-effort; log warning in non-strict mode) 

478 tl = make_transparency_log() 

479 tl_error: str | None = None 

480 tl_entry = None 

481 try: 

482 tl_entry = tl.submit(revoke_event) 

483 except TransparencyLogUnavailable as exc: 

484 tl_error = str(exc) 

485 if settings.trust_mode == "strict": 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true

486 raise HTTPException( 

487 status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 

488 detail=f"transparency log unavailable during revocation (strict mode): {exc}", 

489 ) from exc 

490 logger.warning("TL unavailable during token revocation (warn mode): %s", exc) 

491 

492 revoke_log_json = json.dumps( 

493 { 

494 **revoke_event, 

495 **( 

496 { 

497 "tl_entry": { 

498 "log_id": tl_entry.log_id, 

499 "log_index": tl_entry.log_index, 

500 "leaf_hash": tl_entry.leaf_hash, 

501 } 

502 } 

503 if tl_entry 

504 else {} 

505 ), 

506 } 

507 ) 

508 

509 with db() as conn: 

510 conn.execute( 

511 "UPDATE capability_tokens SET revoked_at = ?, revoke_log = ? WHERE id = ?", 

512 (now, revoke_log_json, token_id), 

513 ) 

514 # M-SEC-4: audit log entry for token revocation (spec §19.3.4) 

515 conn.execute( 

516 """INSERT INTO fact_audit_log 

517 (id, fact_id, event_type, entity_uri, oidc_sub, source, attested_key_id, detail, ts) 

518 VALUES (?,?,?,?,?,?,?,?,?)""", 

519 ( 

520 str(uuid.uuid4()), 

521 token_id, # token_id as surrogate fact_id 

522 "capability_revoked", 

523 identity.entity_uri, 

524 None, 

525 "system:capability", 

526 None, 

527 json.dumps({"reason": reason}), 

528 now, 

529 ), 

530 ) 

531 

532 result: dict[str, Any] = {"token_id": token_id, "revoked_at": now, "status": "revoked"} 

533 if tl_error: 533 ↛ 535line 533 didn't jump to line 535 because the condition on line 533 was always true

534 result["tl_warning"] = tl_error 

535 return result 

536 

537 

538# Quarantine promote/reject routes live in routes/gardens.py (registered before this 

539# router). Do NOT add duplicate promote/reject handlers here — gardens.py owns those 

540# endpoints and enforces the correct quarantine ACL (quarantine-flag check + mandatory 

541# moderator/admin role, no write-permission bypass).