Coverage for node / src / stigmem_node / main.py: 84%

228 statements  

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

1"""Stigmem reference node — FastAPI application factory and entrypoint.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import logging 

7import signal 

8import ssl 

9from collections.abc import AsyncGenerator, Awaitable, Callable 

10from contextlib import asynccontextmanager, suppress 

11from pathlib import Path 

12from typing import Annotated, Any, cast 

13 

14import uvicorn 

15from fastapi import Depends, FastAPI, HTTPException, Request, Response, status 

16from fastapi.middleware.cors import CORSMiddleware 

17from fastapi.responses import FileResponse, JSONResponse 

18 

19from .auth import Identity, resolve_identity, resolve_identity_optional 

20from .body_limit import BodySizeLimitMiddleware 

21from .db import apply_migrations 

22from .net_util import node_url_is_loopback 

23from .rate_limit import RateLimitMiddleware 

24from .routes.admin_audit import router as admin_audit_router 

25from .routes.agent_keys import router as agent_keys_router 

26from .routes.aliases import router as aliases_router 

27from .routes.audit import router as audit_router 

28from .routes.auth import router as auth_router 

29from .routes.cards import router as cards_router 

30from .routes.cid_admin import router as cid_admin_router 

31from .routes.decay import router as decay_router 

32from .routes.facts import router as facts_router 

33from .routes.federation import router as federation_router 

34from .routes.gardens import router as gardens_router 

35from .routes.graph import router as graph_router 

36from .routes.identity import router as identity_router 

37from .routes.intents import router as intents_router 

38from .routes.lint import router as lint_router 

39from .routes.mcp import router as mcp_router 

40from .routes.quarantine import router as quarantine_router 

41from .routes.recall import router as recall_router 

42from .routes.resolver import router as resolver_router 

43from .routes.subscriptions import router as subscriptions_router 

44from .routes.synthesize import router as synthesize_router 

45from .routes.tombstones import router as tombstones_router 

46from .routes.wellknown import router as wellknown_router 

47from .settings import settings 

48 

49_STATIC_DIR = Path(__file__).parent / "static" 

50 

51logger = logging.getLogger("stigmem") 

52 

53_DEV_LOCALHOST_CORS_REGEX = r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$" 

54 

55 

56def _enforce_federation_transport_security() -> None: 

57 """Require explicit opt-in for federation without mTLS.""" 

58 federation_active = settings.federation_enabled or settings.federation_push_enabled 

59 if not federation_active or settings.mtls_enabled: 

60 return 

61 

62 if not settings.federation_insecure: 

63 raise RuntimeError( 

64 "Federation requires mTLS by default. Configure STIGMEM_TLS_CERT_PATH, " 

65 "STIGMEM_TLS_KEY_PATH, and STIGMEM_TLS_CA_BUNDLE, or set " 

66 "STIGMEM_FEDERATION_INSECURE=1 only for local/dev/test federation." 

67 ) 

68 

69 if not _node_url_is_loopback(settings.node_url) and not ( 

70 settings.local_dev_allow_insecure_non_loopback 

71 ): 

72 raise RuntimeError( 

73 "STIGMEM_FEDERATION_INSECURE=1 is only permitted when node_url is " 

74 f"bound to 127.0.0.1 or localhost. Got node_url={settings.node_url!r}. " 

75 "Configure mTLS for any non-loopback deployment, or set " 

76 "STIGMEM_LOCAL_DEV_ALLOW_INSECURE_NON_LOOPBACK=1 only for local " 

77 "Docker/dev networks." 

78 ) 

79 

80 logger.warning( 

81 "SECURITY WARNING: federation is running without mTLS because " 

82 "STIGMEM_FEDERATION_INSECURE=1 is set. This is only allowed because " 

83 "node_url is a loopback address or " 

84 "STIGMEM_LOCAL_DEV_ALLOW_INSECURE_NON_LOOPBACK=1 is set. Use this only " 

85 "for local/dev/test." 

86 ) 

87 

88 

89def _enforce_auth_required_in_production() -> None: 

90 """Refuse to run unauthenticated outside loopback.""" 

91 if settings.auth_required: 

92 return 

93 if not _node_url_is_loopback(settings.node_url) and not ( 

94 settings.local_dev_allow_insecure_non_loopback 

95 ): 

96 raise RuntimeError( 

97 "STIGMEM_AUTH_REQUIRED=false is only permitted when node_url is " 

98 f"bound to 127.0.0.1 or localhost. Got node_url={settings.node_url!r}. " 

99 "Anonymous identity has read/write/federate permissions; never expose " 

100 "this configuration to a network. Set " 

101 "STIGMEM_LOCAL_DEV_ALLOW_INSECURE_NON_LOOPBACK=1 only for local " 

102 "Docker/dev networks." 

103 ) 

104 logger.warning( 

105 "SECURITY WARNING: STIGMEM_AUTH_REQUIRED=false. Anonymous identity has " 

106 "full read/write/federate permissions. This is only allowed because " 

107 "node_url is a loopback address or " 

108 "STIGMEM_LOCAL_DEV_ALLOW_INSECURE_NON_LOOPBACK=1 is set." 

109 ) 

110 

111 

112def _enforce_rate_limit_kill_switch_ack() -> None: 

113 """Refuse boot when quota is fully disabled without an explicit acknowledgement.""" 

114 if settings.rate_limit_write_per_hour != 0 or settings.rate_limit_read_per_hour != 0: 

115 return 

116 if not settings.rate_limit_disabled_ack: 

117 raise RuntimeError( 

118 "STIGMEM_RATE_LIMIT_WRITE_PER_HOUR=0 and " 

119 "STIGMEM_RATE_LIMIT_READ_PER_HOUR=0 fully disable quota enforcement. " 

120 "To proceed, set STIGMEM_RATE_LIMIT_DISABLED_ACK=1 to acknowledge " 

121 "that this node accepts unbounded read and write traffic." 

122 ) 

123 

124 logger.warning( 

125 "SECURITY WARNING: quota enforcement is fully disabled " 

126 "(write=0, read=0) with explicit operator acknowledgment via " 

127 "STIGMEM_RATE_LIMIT_DISABLED_ACK=1." 

128 ) 

129 

130 

131def _warn_if_cors_dev_localhost_enabled() -> None: 

132 """Log the expanded development CORS posture at startup.""" 

133 if settings.cors_dev_localhost: 

134 logger.warning( 

135 "SECURITY WARNING: STIGMEM_CORS_DEV_LOCALHOST=1 enables browser " 

136 "access from localhost and loopback origins. Use this only for " 

137 "local development." 

138 ) 

139 

140 

141def _warn_if_backend_immutability_unenforced() -> None: 

142 """Warn when the storage backend lacks DB-level facts-immutability triggers. 

143 

144 P-DESTROY-1 (honesty): the append-only no-UPDATE/no-DELETE triggers 

145 (ADR-016 L2) are implemented for SQLite only. On libsql/Postgres the facts 

146 table is mutable by anyone with direct database write access, so tamper 

147 resistance on those backends rests on the L3+ CID / hash-chain layers, not 

148 L2 triggers. State that loudly rather than imply uniform immutability. 

149 """ 

150 backend = getattr(settings, "storage_backend", "sqlite") 

151 if backend == "sqlite": 

152 return 

153 logger.warning( 

154 "SECURITY WARNING: storage_backend=%r does NOT enforce database-level " 

155 "facts immutability. The append-only no-UPDATE/no-DELETE triggers " 

156 "(ADR-016 L2) exist for SQLite only; on %s the facts table is mutable " 

157 "by anyone with direct DB write access. Tamper-evidence on this backend " 

158 "relies on the L3+ CID / hash-chain layers, not L2 triggers.", 

159 backend, 

160 backend, 

161 ) 

162 

163 

164def _warn_if_legacy_sha256_acceptance_unbounded() -> None: 

165 """Warn when legacy SHA-256 API-key hashes are accepted with no cutoff (F-ID-4). 

166 

167 Unsalted SHA-256 is weaker than the Argon2id default. The acceptance window 

168 is a v0.9.x migration affordance; leaving it open indefinitely 

169 (STIGMEM_LEGACY_SHA256_ACCEPT_UNTIL unset) is a standing weakness. 

170 """ 

171 if settings.legacy_sha256_accept_until is not None: 

172 return 

173 logger.warning( 

174 "SECURITY WARNING: legacy SHA-256 API-key hashes are accepted with no " 

175 "cutoff (STIGMEM_LEGACY_SHA256_ACCEPT_UNTIL is unset). Unsalted SHA-256 " 

176 "is weaker than the Argon2id default; set a cutoff date to bound the " 

177 "migration window and force rotation of any keys not yet rehashed." 

178 ) 

179 

180 

181def _node_url_is_loopback(node_url: str) -> bool: 

182 """Return True iff node_url's host is a loopback address.""" 

183 return node_url_is_loopback(node_url) 

184 

185 

186def create_app() -> FastAPI: 

187 @asynccontextmanager 

188 async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: 

189 from .plugins import get_registry, register_discovered_plugins 

190 

191 if settings.trust_mode == "strict" and not settings.node_private_key: 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true

192 raise RuntimeError("STIGMEM_NODE_PRIVATE_KEY must be set when trust_mode=strict") 

193 _enforce_federation_transport_security() 

194 _enforce_auth_required_in_production() 

195 _enforce_rate_limit_kill_switch_ack() 

196 _warn_if_cors_dev_localhost_enabled() 

197 _warn_if_backend_immutability_unenforced() 

198 _warn_if_legacy_sha256_acceptance_unbounded() 

199 

200 discovered_plugins = register_discovered_plugins(freeze=False) 

201 _include_plugin_routers(app, discovered_plugins) 

202 apply_migrations() 

203 from .memory_garden_acl_gate import warn_if_memory_garden_acl_filtering_disabled 

204 

205 warn_if_memory_garden_acl_filtering_disabled(logger) 

206 get_registry().freeze() 

207 

208 if settings.otel_enabled: 208 ↛ 209line 208 didn't jump to line 209 because the condition on line 208 was never true

209 from .observability.tracing import init_tracing 

210 

211 init_tracing( 

212 service_name=settings.otel_service_name, 

213 otlp_endpoint=settings.otel_exporter_otlp_endpoint, 

214 ) 

215 

216 pull_task: asyncio.Task[None] | None = None 

217 if settings.federation_enabled: 

218 from .federation.federation_pull import pull_loop_task 

219 from .federation.peer_token import init_federation_keys 

220 

221 init_federation_keys() 

222 pull_task = asyncio.create_task(pull_loop_task()) 

223 logger.info("Federation enabled — pull %ds", settings.federation_pull_interval_s) 

224 

225 from .subscription_delivery import sweep_loop as _sub_sweep_loop 

226 

227 sweep_task: asyncio.Task[None] = asyncio.create_task(_sub_sweep_loop()) 

228 logger.info( 

229 "Stigmem subscription sweep enabled — interval %ds", 

230 settings.subscription_delivery_sweep_s, 

231 ) 

232 

233 logger.info( 

234 "Stigmem node ready — db=%s auth=%s federation=%s", 

235 settings.db_path, 

236 settings.auth_required, 

237 "enabled" if settings.federation_enabled else "disabled", 

238 ) 

239 yield 

240 

241 sweep_task.cancel() 

242 with suppress(asyncio.CancelledError): 

243 _unused_result = await cast("asyncio.Task[object]", sweep_task) 

244 

245 if pull_task is not None: 

246 pull_task.cancel() 

247 with suppress(asyncio.CancelledError): 

248 _unused_result = await cast("asyncio.Task[object]", pull_task) 

249 

250 app = FastAPI( 

251 title="Stigmem Reference Node", 

252 version="0.9.0a12", 

253 description=( 

254 "Reference node implementing the Stigmem v0.9.0a12 HTTP API — facts, federation, " 

255 "gardens, recall, subscriptions, audit, identity, content-addressed fact IDs. " 

256 "Experimental cross-cutting behavior remains opt-in where plugin-gated; tombstone " 

257 "admin and federation route contracts are mounted but access-gated." 

258 ), 

259 license_info={"name": "Apache-2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0"}, 

260 lifespan=lifespan, 

261 ) 

262 

263 app.add_middleware(RateLimitMiddleware) 

264 app.add_middleware(BodySizeLimitMiddleware) 

265 _cors_regex = settings.cors_allowed_origin_regex 

266 if settings.cors_dev_localhost: 

267 _cors_regex = _DEV_LOCALHOST_CORS_REGEX 

268 if settings.cors_allowed_origins or _cors_regex: 

269 app.add_middleware( 

270 CORSMiddleware, 

271 allow_origins=settings.cors_allowed_origins, 

272 allow_origin_regex=_cors_regex, 

273 allow_credentials=settings.cors_allow_credentials, 

274 allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], 

275 allow_headers=["*"], 

276 expose_headers=["ETag"], 

277 max_age=600, 

278 ) 

279 

280 @app.middleware("http") 

281 async def unsigned_plugin_override_warning( 

282 _request: Request, 

283 call_next: Callable[[Request], Awaitable[Response]], 

284 ) -> Response: 

285 """Warn every request while development unsigned-plugin override is active.""" 

286 from .plugins import get_registry 

287 

288 unsigned_plugins = get_registry().development_unsigned_plugins() 

289 if unsigned_plugins: 289 ↛ 290line 289 didn't jump to line 290 because the condition on line 289 was never true

290 logger.warning( 

291 "SECURITY WARNING: unsigned plugins active via " 

292 "STIGMEM_PLUGIN_SIGNING_REQUIRED=false: %s", 

293 ", ".join(unsigned_plugins), 

294 ) 

295 return await call_next(_request) 

296 

297 if settings.mtls_enabled: 

298 

299 @app.middleware("http") 

300 async def mtls_plaintext_guard( 

301 request: Request, 

302 call_next: Callable[[Request], Awaitable[Response]], 

303 ) -> Response: 

304 """Reject plaintext federation requests when mTLS is configured (§22.1).""" 

305 if request.method == "OPTIONS" and not request.url.path.startswith("/v1/federation"): 

306 return await call_next(request) 

307 if request.url.path.startswith("/v1/federation") and request.url.scheme != "https": 

308 return JSONResponse( 

309 { 

310 "error": "mTLS required", 

311 "detail": "Federation transport requires mutual TLS (spec §22.1). " 

312 "Connect via HTTPS with a valid node certificate.", 

313 }, 

314 status_code=421, 

315 ) 

316 return await call_next(request) 

317 

318 app.include_router(admin_audit_router) 

319 app.include_router(cid_admin_router) 

320 app.include_router(auth_router) 

321 app.include_router(agent_keys_router) 

322 app.include_router(audit_router) 

323 app.include_router(facts_router) 

324 app.include_router(gardens_router) 

325 app.include_router(graph_router) 

326 app.include_router(identity_router) 

327 app.include_router(intents_router) 

328 app.include_router(federation_router) 

329 app.include_router(quarantine_router) 

330 app.include_router(lint_router) 

331 app.include_router(synthesize_router) 

332 app.include_router(decay_router) 

333 app.include_router(aliases_router) 

334 app.include_router(resolver_router) 

335 app.include_router(cards_router) 

336 app.include_router(recall_router) 

337 app.include_router(subscriptions_router) 

338 app.include_router(tombstones_router) 

339 app.include_router(mcp_router) 

340 app.include_router(wellknown_router) 

341 

342 @app.get("/healthz", tags=["ops"]) 

343 def health() -> dict[str, str]: 

344 return {"status": "ok"} 

345 

346 @app.get("/v1/doctor", tags=["ops"]) 

347 def doctor() -> dict[str, str]: 

348 """Return coarse node health and operator posture. 

349 

350 This endpoint is unauthenticated in v0.9.0a12. The garden ACL posture 

351 field is accepted as ops-endpoint disclosure and intentionally avoids 

352 garden names, membership rows, tenant identifiers, or policy subjects. 

353 """ 

354 from .memory_garden_acl_gate import memory_garden_acl_filtering_state 

355 

356 return { 

357 "status": "ok", 

358 "memory_garden_acl_filtering": memory_garden_acl_filtering_state(), 

359 } 

360 

361 @app.get("/metrics", include_in_schema=False, tags=["ops"]) 

362 def prometheus_metrics( 

363 identity: Annotated[Identity | None, Depends(resolve_identity_optional)], 

364 ) -> Response: 

365 # M11 / F-AVAIL-1: /metrics leaks per-principal labels + enables cardinality 

366 # DoS, so require the admin capability by default. Set 

367 # STIGMEM_METRICS_REQUIRE_AUTH=false only where /metrics is otherwise 

368 # access-controlled (e.g. a private scrape interface or sidecar proxy). 

369 if settings.metrics_require_auth: 

370 if identity is None: 

371 raise HTTPException( 

372 status_code=status.HTTP_401_UNAUTHORIZED, 

373 detail="Authorization required to scrape /metrics", 

374 headers={"WWW-Authenticate": "Bearer"}, 

375 ) 

376 if not identity.is_admin(): 

377 raise HTTPException( 

378 status_code=status.HTTP_403_FORBIDDEN, 

379 detail="admin capability required to scrape /metrics", 

380 ) 

381 

382 from .observability.metrics import make_metrics_response 

383 

384 resp = make_metrics_response() 

385 if resp is None: 385 ↛ 389line 385 didn't jump to line 389 because the condition on line 385 was always true

386 from fastapi.responses import PlainTextResponse 

387 

388 return PlainTextResponse("# prometheus_client not installed\n", status_code=200) 

389 return resp 

390 

391 @app.get("/v1/me", tags=["auth"]) 

392 def whoami(identity: Annotated[Identity, Depends(resolve_identity)]) -> dict[str, Any]: 

393 return { 

394 "entity_uri": identity.entity_uri, 

395 "permissions": sorted(identity.permissions), 

396 "oidc_sub": identity.oidc_sub, 

397 "tenant_id": identity.tenant_id, 

398 } 

399 

400 @app.get("/ui", include_in_schema=False) 

401 def ui_index() -> FileResponse: 

402 return FileResponse(_STATIC_DIR / "index.html", media_type="text/html") 

403 

404 return app 

405 

406 

407def _include_plugin_routers(app: FastAPI, discovered_plugins: tuple[Any, ...]) -> None: 

408 """Include routers declared by installed plugins once per app instance.""" 

409 if getattr(app.state, "stigmem_plugin_routes_included", False): 

410 return 

411 for plugin in discovered_plugins: 

412 for router in plugin.manifest.routes: 

413 app.include_router(router) 

414 app.state.stigmem_plugin_routes_included = True 

415 

416 

417app = create_app() 

418 

419 

420def run() -> None: 

421 if not settings.mtls_enabled: 

422 uvicorn.run( 

423 "stigmem_node.main:app", 

424 host=settings.host, 

425 port=settings.port, 

426 log_level=settings.log_level, 

427 reload=False, 

428 ) 

429 return 

430 

431 from .federation.tls import cert_watcher_task, reload_tls_cert 

432 

433 # Let uvicorn build the SSL context from cert/key files, then enforce TLS 1.3 

434 # floor and mTLS client-cert requirement on the resulting context object. 

435 config = uvicorn.Config( 

436 "stigmem_node.main:app", 

437 host=settings.host, 

438 port=settings.port, 

439 log_level=settings.log_level, 

440 reload=False, 

441 ssl_certfile=settings.tls_cert_path, 

442 ssl_keyfile=settings.tls_key_path, 

443 ssl_ca_certs=settings.tls_ca_bundle or None, 

444 ssl_cert_reqs=ssl.CERT_REQUIRED, 

445 ) 

446 config.load() 

447 

448 if config.ssl: 

449 config.ssl.minimum_version = ssl.TLSVersion.TLSv1_3 

450 ssl_ctx = config.ssl 

451 

452 async def _serve_with_cert_watcher() -> None: 

453 loop = asyncio.get_running_loop() 

454 if ssl_ctx is not None: 

455 loop.add_signal_handler( 

456 signal.SIGHUP, 

457 lambda: reload_tls_cert(ssl_ctx), 

458 ) 

459 

460 server = uvicorn.Server(config) 

461 watcher_task: asyncio.Task[None] | None = None 

462 if ssl_ctx is not None: 

463 watcher_task = asyncio.create_task(cert_watcher_task(ssl_ctx)) 

464 

465 try: 

466 await server.serve() 

467 finally: 

468 if watcher_task is not None: 

469 watcher_task.cancel() 

470 with suppress(asyncio.CancelledError): 

471 _unused_result = await cast("asyncio.Task[object]", watcher_task) 

472 

473 asyncio.run(_serve_with_cert_watcher()) 

474 

475 

476if __name__ == "__main__": 476 ↛ 477line 476 didn't jump to line 477 because the condition on line 476 was never true

477 run()