Coverage for node / src / stigmem_node / routes / subscriptions.py: 88%

117 statements  

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

1"""Subscription CRUD and replay-window routes — spec §20.3, §20.5. 

2 

3POST /v1/subscriptions — create (subscriber_identity = caller; BOLA §20.3.2) 

4GET /v1/subscriptions — list caller's subscriptions 

5GET /v1/subscriptions/{id} — get one (BOLA: 404 if not owner) 

6DELETE /v1/subscriptions/{id} — cancel (BOLA: 404 if not owner) 

7GET /v1/subscriptions/{id}/events — replay window (§20.5) 

8""" 

9 

10from __future__ import annotations 

11 

12import json 

13import uuid 

14from datetime import UTC, datetime, timedelta 

15from typing import Annotated, Any 

16 

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

18 

19from .. import settings as _settings_pkg 

20from ..auth import Identity, resolve_identity 

21from ..db import db 

22from ..garden_acl import get_garden_by_garden_uri, require_garden_read 

23from ..models.constants import VALID_SCOPES 

24from ..models.subscriptions import ( 

25 SubscriptionCreateRequest, 

26 SubscriptionEventRecord, 

27 SubscriptionEventsResponse, 

28 SubscriptionListResponse, 

29 SubscriptionRecord, 

30) 

31from ..net_util import assert_safe_url 

32from ..subscription_delivery import _sanitize_payload as _sanitize_event_payload 

33 

34router = APIRouter(prefix="/v1/subscriptions", tags=["subscriptions"]) 

35 

36 

37def _target_kind(target: str) -> str: 

38 return "scope" if target in VALID_SCOPES else "entity" 

39 

40 

41def _row_to_record(row: Any) -> SubscriptionRecord: 

42 return SubscriptionRecord( 

43 id=row["id"], 

44 subscriber_identity=row["subscriber_identity"], 

45 target=row["target"], 

46 target_kind=row["target_kind"], 

47 on_change=row["on_change"], 

48 delivery_address=row["delivery_address"], 

49 idempotency_key=row["idempotency_key"], 

50 created_at=row["created_at"], 

51 last_delivered_at=row["last_delivered_at"], 

52 circuit_open=bool(row["circuit_open"]), 

53 consecutive_failures=row["consecutive_failures"], 

54 ) 

55 

56 

57def _event_row_to_record(row: Any) -> SubscriptionEventRecord: 

58 return SubscriptionEventRecord( 

59 id=row["id"], 

60 subscription_id=row["subscription_id"], 

61 event_type=row["event_type"], 

62 entity_uri=row["entity_uri"], 

63 fact_id=row["fact_id"], 

64 payload=json.loads(row["payload"]), 

65 created_at=row["created_at"], 

66 delivered_at=row["delivered_at"], 

67 delivery_status=row["delivery_status"], 

68 delivery_attempts=row["delivery_attempts"], 

69 ) 

70 

71 

72def _event_row_to_record_with_payload(row: Any, payload_json: str) -> SubscriptionEventRecord: 

73 """Like _event_row_to_record but substitutes a pre-sanitized payload JSON string.""" 

74 return SubscriptionEventRecord( 

75 id=row["id"], 

76 subscription_id=row["subscription_id"], 

77 event_type=row["event_type"], 

78 entity_uri=row["entity_uri"], 

79 fact_id=row["fact_id"], 

80 payload=json.loads(payload_json), 

81 created_at=row["created_at"], 

82 delivered_at=row["delivered_at"], 

83 delivery_status=row["delivery_status"], 

84 delivery_attempts=row["delivery_attempts"], 

85 ) 

86 

87 

88# --------------------------------------------------------------------------- 

89# CRUD 

90# --------------------------------------------------------------------------- 

91 

92 

93@router.post("", response_model=SubscriptionRecord, status_code=status.HTTP_201_CREATED) 

94def create_subscription( 

95 req: SubscriptionCreateRequest, 

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

97) -> SubscriptionRecord: 

98 """Create a subscription (Spec-X7-Subscriptions). 

99 

100 subscriber_identity is always the caller (BOLA). 

101 """ 

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

103 raise HTTPException( 

104 status_code=status.HTTP_403_FORBIDDEN, detail="read permission required" 

105 ) 

106 

107 # §17 garden ACL: if target is a garden URI, caller must be a member 

108 if req.target.startswith("stigmem://") and "/garden/" in req.target: 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true

109 garden = get_garden_by_garden_uri(req.target, tenant_id=identity.tenant_id) 

110 if garden is None: 

111 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="garden not found") 

112 require_garden_read(garden, identity) 

113 

114 # GHSA-5p3m-vhh6-9236: validate a webhook delivery_address BEFORE persisting, 

115 # so an unsafe URL is rejected (400) rather than stored-then-blocked-at-delivery. 

116 # https-only by default; http requires the explicit operator opt-in. A `wake` 

117 # subscription's delivery_address is an identity URI (not a fetchable URL), so 

118 # it is deliberately NOT subjected to the SSRF guard. 

119 if req.on_change == "webhook": 

120 try: 

121 assert_safe_url( 

122 req.delivery_address, 

123 allow_schemes=_settings_pkg.settings.webhook_allowed_schemes, 

124 ) 

125 except ValueError as exc: 

126 raise HTTPException( 

127 status_code=status.HTTP_400_BAD_REQUEST, 

128 detail=f"unsafe webhook delivery_address: {exc}", 

129 ) from exc 

130 

131 # Idempotency key: return existing subscription if key matches THIS caller. 

132 # Scoped to subscriber and tenant to prevent cross-entity metadata leakage (R2). 

133 if req.idempotency_key: 

134 with db() as conn: 

135 existing = conn.execute( 

136 """SELECT * FROM subscriptions 

137 WHERE idempotency_key=? AND tenant_id=? AND subscriber_identity=?""", 

138 (req.idempotency_key, identity.tenant_id, identity.entity_uri), 

139 ).fetchone() 

140 if existing is not None: 

141 return _row_to_record(existing) 

142 

143 # Natural dedup: same (subscriber, target, on_change, delivery_address) → return existing 

144 with db() as conn: 

145 dupe = conn.execute( 

146 """SELECT * FROM subscriptions 

147 WHERE subscriber_identity=? AND target=? AND on_change=? 

148 AND delivery_address=? AND tenant_id=?""", 

149 ( 

150 identity.entity_uri, 

151 req.target, 

152 req.on_change, 

153 req.delivery_address, 

154 identity.tenant_id, 

155 ), 

156 ).fetchone() 

157 if dupe is not None: 

158 return _row_to_record(dupe) 

159 

160 # F-AVAIL-3: cap active subscriptions per principal to bound fan-out/DoS. 

161 _sub_cap = _settings_pkg.settings.max_subscriptions_per_principal 

162 if _sub_cap > 0: 

163 with db() as conn: 

164 held = conn.execute( 

165 "SELECT COUNT(*) AS n FROM subscriptions " 

166 "WHERE subscriber_identity=? AND tenant_id=?", 

167 (identity.entity_uri, identity.tenant_id), 

168 ).fetchone()["n"] 

169 if held >= _sub_cap: 

170 raise HTTPException( 

171 status_code=status.HTTP_429_TOO_MANY_REQUESTS, 

172 detail=f"subscription limit reached ({_sub_cap})", 

173 ) 

174 

175 sub_id = str(uuid.uuid4()) 

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

177 target_kind = _target_kind(req.target) 

178 

179 with db() as conn: 

180 conn.execute( 

181 """INSERT INTO subscriptions 

182 (id, subscriber_identity, target, target_kind, on_change, 

183 delivery_address, idempotency_key, created_at, tenant_id) 

184 VALUES (?,?,?,?,?,?,?,?,?)""", 

185 ( 

186 sub_id, 

187 identity.entity_uri, 

188 req.target, 

189 target_kind, 

190 req.on_change, 

191 req.delivery_address, 

192 req.idempotency_key, 

193 now, 

194 identity.tenant_id, 

195 ), 

196 ) 

197 row = conn.execute("SELECT * FROM subscriptions WHERE id=?", (sub_id,)).fetchone() 

198 

199 return _row_to_record(row) 

200 

201 

202@router.get("", response_model=SubscriptionListResponse) 

203def list_subscriptions( 

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

205) -> SubscriptionListResponse: 

206 """List the authenticated caller's subscriptions (BOLA: own only).""" 

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

208 raise HTTPException( 

209 status_code=status.HTTP_403_FORBIDDEN, detail="read permission required" 

210 ) 

211 

212 with db() as conn: 

213 rows = conn.execute( 

214 """SELECT * FROM subscriptions 

215 WHERE subscriber_identity=? AND tenant_id=? 

216 ORDER BY created_at DESC""", 

217 (identity.entity_uri, identity.tenant_id), 

218 ).fetchall() 

219 

220 return SubscriptionListResponse( 

221 subscriptions=[_row_to_record(r) for r in rows], 

222 total=len(rows), 

223 ) 

224 

225 

226@router.get("/{subscription_id}", response_model=SubscriptionRecord) 

227def get_subscription( 

228 subscription_id: str, 

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

230) -> SubscriptionRecord: 

231 """Get a subscription by ID (BOLA: returns 404 if caller does not own it).""" 

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

233 raise HTTPException( 

234 status_code=status.HTTP_403_FORBIDDEN, detail="read permission required" 

235 ) 

236 

237 with db() as conn: 

238 row = conn.execute( 

239 "SELECT * FROM subscriptions WHERE id=? AND tenant_id=?", 

240 (subscription_id, identity.tenant_id), 

241 ).fetchone() 

242 

243 if row is None or row["subscriber_identity"] != identity.entity_uri: 

244 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="subscription not found") 

245 

246 return _row_to_record(row) 

247 

248 

249@router.delete("/{subscription_id}", status_code=status.HTTP_204_NO_CONTENT) 

250def delete_subscription( 

251 subscription_id: str, 

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

253) -> None: 

254 """Cancel a subscription (BOLA: only the owner may delete).""" 

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

256 raise HTTPException( 

257 status_code=status.HTTP_403_FORBIDDEN, detail="read permission required" 

258 ) 

259 

260 with db() as conn: 

261 row = conn.execute( 

262 "SELECT id, subscriber_identity FROM subscriptions WHERE id=? AND tenant_id=?", 

263 (subscription_id, identity.tenant_id), 

264 ).fetchone() 

265 

266 if row is None or row["subscriber_identity"] != identity.entity_uri: 

267 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="subscription not found") 

268 

269 with db() as conn: 

270 conn.execute("DELETE FROM subscriptions WHERE id=?", (subscription_id,)) 

271 

272 

273# --------------------------------------------------------------------------- 

274# Replay window 

275# --------------------------------------------------------------------------- 

276 

277 

278@router.get("/{subscription_id}/events", response_model=SubscriptionEventsResponse) 

279def list_subscription_events( 

280 subscription_id: str, 

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

282 since: str | None = Query( 

283 None, 

284 description="ISO 8601 timestamp; return events created at or after this time", 

285 ), 

286 cursor: str | None = Query(None, description="Opaque pagination cursor (event id)"), 

287 limit: int = Query(50, ge=1, le=500), 

288) -> SubscriptionEventsResponse: 

289 """Replay window: return delivery events for a subscription (Spec-X7-Subscriptions). 

290 

291 Results are bounded to the configured replay window (default 24 h). 

292 """ 

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

294 raise HTTPException( 

295 status_code=status.HTTP_403_FORBIDDEN, detail="read permission required" 

296 ) 

297 

298 with db() as conn: 

299 sub_row = conn.execute( 

300 "SELECT id, subscriber_identity FROM subscriptions WHERE id=? AND tenant_id=?", 

301 (subscription_id, identity.tenant_id), 

302 ).fetchone() 

303 

304 if sub_row is None or sub_row["subscriber_identity"] != identity.entity_uri: 

305 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="subscription not found") 

306 

307 replay_s = _settings_pkg.settings.subscription_replay_s 

308 window_cutoff = (datetime.now(UTC) - timedelta(seconds=replay_s)).isoformat() 

309 effective_since = max(since, window_cutoff) if since is not None else window_cutoff 

310 

311 if cursor: 

312 sql = ( 

313 "SELECT rowid, * FROM subscription_events " 

314 "WHERE subscription_id = ? AND created_at >= ? AND rowid > ? " 

315 "ORDER BY rowid ASC LIMIT ?" 

316 ) 

317 params: list[Any] = [subscription_id, effective_since, int(cursor)] 

318 else: 

319 sql = ( 

320 "SELECT rowid, * FROM subscription_events " 

321 "WHERE subscription_id = ? AND created_at >= ? " 

322 "ORDER BY rowid ASC LIMIT ?" 

323 ) 

324 params = [subscription_id, effective_since] 

325 params.append(limit + 1) 

326 

327 with db() as conn: 

328 rows = conn.execute(sql, params).fetchall() 

329 

330 has_more = len(rows) > limit 

331 rows = rows[:limit] 

332 next_cursor = str(rows[-1]["rowid"]) if has_more and rows else None 

333 

334 # S3 fix: re-apply §17 garden ACL and §19 sanitizer at replay time. 

335 # subscription_events.payload stores the raw pre-delivery payload; without this 

336 # re-check a subscriber could retrieve garden-scoped facts they've since been 

337 # removed from, or sanitizer-redacted content, via the replay window. 

338 event_ctx = { 

339 "subscriber_identity": sub_row["subscriber_identity"], 

340 "tenant_id": identity.tenant_id, 

341 } 

342 safe_events: list[SubscriptionEventRecord] = [] 

343 for r in rows: 

344 raw_payload = json.loads(r["payload"]) 

345 sanitized = _sanitize_event_payload(event_ctx, raw_payload) 

346 if sanitized is None: 

347 # ACL/sanitizer blocked — return redacted placeholder 

348 safe_payload = json.dumps({"fact_id": raw_payload.get("id"), "redacted": True}) 

349 else: 

350 safe_payload = json.dumps(sanitized) 

351 safe_events.append(_event_row_to_record_with_payload(r, safe_payload)) 

352 

353 return SubscriptionEventsResponse( 

354 events=safe_events, 

355 total=len(safe_events), 

356 cursor=next_cursor, 

357 )