Coverage for node / src / stigmem_node / routes / gardens.py: 82%
186 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-25 01:49 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-25 01:49 +0000
1"""Garden CRUD + membership routes — spec §5.14–§5.18, §17."""
3from __future__ import annotations
5import re
6import uuid
7from datetime import UTC, datetime
8from typing import Annotated, Any
10from fastapi import APIRouter, Depends, HTTPException, status
12from ..audit_event import INSTRUCTION_PROMOTED, emit_instruction_event_if_applicable
13from ..auth import Identity, resolve_identity
14from ..billing import BillingEvent, get_hook_bus
15from ..db import db
16from ..garden_acl import (
17 get_garden_by_slug_or_id,
18 get_member_role,
19 is_node_admin,
20 quarantine_garden_has_pending_facts,
21 require_garden_admin,
22 require_garden_read,
23 require_quarantine_moderator_or_admin,
24)
25from ..lifecycle.immutability import (
26 set_fact_garden_membership,
27 set_fact_quarantine_status,
28 set_fact_validity_override,
29)
30from ..models.constants import VALID_SCOPES
31from ..models.gardens import (
32 GardenCreateRequest,
33 GardenMemberRecord,
34 GardenMemberRequest,
35 GardenMemberUpdateRequest,
36 GardenRecord,
37 QuarantinePromoteRequest,
38 QuarantineRejectRequest,
39)
40from ..settings import settings
42router = APIRouter(prefix="/v1/gardens", tags=["gardens"])
44_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{0,62}$")
47def _build_garden_id_uri(slug: str) -> str:
48 """Construct the canonical garden_id URI from a slug."""
49 from urllib.parse import urlparse
51 parsed = urlparse(settings.node_url)
52 authority = parsed.netloc or parsed.path
53 return f"stigmem://{authority}/garden/{slug}"
56def _members_for_garden(garden_uuid: str) -> list[GardenMemberRecord]:
57 with db() as conn:
58 rows = conn.execute(
59 "SELECT * FROM garden_members WHERE garden_id = ? ORDER BY added_at",
60 (garden_uuid,),
61 ).fetchall()
62 return [
63 GardenMemberRecord(
64 entity_uri=r["entity_uri"],
65 role=r["role"],
66 added_by=r["added_by"],
67 added_at=r["added_at"],
68 )
69 for r in rows
70 ]
73def _row_to_garden_record(row: dict[str, Any], include_members: bool = True) -> GardenRecord:
74 members = _members_for_garden(row["id"]) if include_members else []
75 return GardenRecord(
76 id=row["id"],
77 garden_id=_build_garden_id_uri(row["slug"]),
78 slug=row["slug"],
79 name=row["name"],
80 scope=row["scope"],
81 description=row.get("description"),
82 created_by=row["created_by"],
83 created_at=row["created_at"],
84 members=members,
85 quarantine=bool(row.get("quarantine", 0)),
86 )
89@router.post("", response_model=GardenRecord, status_code=status.HTTP_201_CREATED)
90def create_garden(
91 req: GardenCreateRequest,
92 identity: Annotated[Identity, Depends(resolve_identity)],
93) -> GardenRecord:
94 """Create a new garden (Spec-02-Scopes-and-ACL). Creator is auto-added as admin."""
95 if not identity.can_write(): 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true
96 raise HTTPException(
97 status_code=status.HTTP_403_FORBIDDEN, detail="write permission required"
98 )
100 slug = req.slug.lower().strip()
101 if not _SLUG_RE.match(slug):
102 raise HTTPException(
103 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
104 detail="slug must match ^[a-z0-9][a-z0-9\\-]{0,62}$",
105 )
106 if req.scope not in VALID_SCOPES: 106 ↛ 107line 106 didn't jump to line 107 because the condition on line 106 was never true
107 raise HTTPException(
108 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
109 detail=f"scope must be one of {VALID_SCOPES}",
110 )
112 garden_uuid = str(uuid.uuid4())
113 now = datetime.now(UTC).isoformat()
115 with db() as conn:
116 existing = conn.execute(
117 "SELECT id FROM gardens WHERE slug = ? AND tenant_id = ?",
118 (slug, identity.tenant_id),
119 ).fetchone()
120 if existing:
121 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="slug already exists")
123 conn.execute(
124 """INSERT INTO gardens
125 (id, slug, name, scope, description, created_by, created_at,
126 tenant_id, quarantine)
127 VALUES (?,?,?,?,?,?,?,?,?)""",
128 (
129 garden_uuid,
130 slug,
131 req.name,
132 req.scope,
133 req.description,
134 identity.entity_uri,
135 now,
136 identity.tenant_id,
137 int(req.quarantine),
138 ),
139 )
140 conn.execute(
141 """INSERT INTO garden_members (garden_id, entity_uri, role, added_by, added_at)
142 VALUES (?,?,?,?,?)""",
143 (garden_uuid, identity.entity_uri, "admin", identity.entity_uri, now),
144 )
145 row = conn.execute("SELECT * FROM gardens WHERE id = ?", (garden_uuid,)).fetchone()
147 get_hook_bus().emit(
148 BillingEvent(
149 event_type="garden_created",
150 tenant_id=identity.tenant_id,
151 entity_uri=identity.entity_uri,
152 garden_id=_build_garden_id_uri(slug),
153 )
154 )
156 return _row_to_garden_record(dict(row))
159@router.get("", response_model=list[GardenRecord])
160def list_gardens(
161 identity: Annotated[Identity, Depends(resolve_identity)],
162) -> list[GardenRecord]:
163 """List accessible gardens (Spec-02-Scopes-and-ACL).
165 Node admins see all; others see only their gardens.
166 """
167 if not identity.can_read(): 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 raise HTTPException(
169 status_code=status.HTTP_403_FORBIDDEN, detail="read permission required"
170 )
172 with db() as conn:
173 if is_node_admin(identity): 173 ↛ 179line 173 didn't jump to line 179 because the condition on line 173 was always true
174 rows = conn.execute(
175 "SELECT * FROM gardens WHERE tenant_id = ? ORDER BY created_at",
176 (identity.tenant_id,),
177 ).fetchall()
178 else:
179 rows = conn.execute(
180 """SELECT g.* FROM gardens g
181 JOIN garden_members m ON g.id = m.garden_id
182 WHERE m.entity_uri = ? AND g.tenant_id = ?
183 ORDER BY g.created_at""",
184 (identity.entity_uri, identity.tenant_id),
185 ).fetchall()
187 return [_row_to_garden_record(dict(r), include_members=False) for r in rows]
190@router.get("/{garden_slug_or_id}", response_model=GardenRecord)
191def get_garden(
192 garden_slug_or_id: str,
193 identity: Annotated[Identity, Depends(resolve_identity)],
194) -> GardenRecord:
195 """Get a garden by slug or UUID (Spec-02-Scopes-and-ACL)."""
196 if not identity.can_read(): 196 ↛ 197line 196 didn't jump to line 197 because the condition on line 196 was never true
197 raise HTTPException(
198 status_code=status.HTTP_403_FORBIDDEN, detail="read permission required"
199 )
201 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id)
202 if garden is None:
203 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="garden not found")
205 if not is_node_admin(identity):
206 require_garden_read(garden, identity)
208 return _row_to_garden_record(garden)
211@router.delete("/{garden_slug_or_id}", status_code=status.HTTP_204_NO_CONTENT)
212def delete_garden(
213 garden_slug_or_id: str,
214 identity: Annotated[Identity, Depends(resolve_identity)],
215) -> None:
216 """Delete a garden (Spec-02-Scopes-and-ACL).
218 Requires garden admin role. Facts are orphaned, not deleted.
219 Quarantine gardens with pending facts cannot be deleted
220 (Spec-08-Quarantine-Garden; HTTP 409).
221 """
222 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id)
223 if garden is None:
224 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="garden not found")
226 require_garden_admin(garden, identity)
228 # Guard: quarantine garden cannot be deleted while it has pending facts (§19.5.2)
229 if garden.get("quarantine") and quarantine_garden_has_pending_facts(garden["id"]):
230 raise HTTPException(
231 status_code=status.HTTP_409_CONFLICT,
232 detail="quarantine_has_pending_facts",
233 )
235 with db() as conn:
236 conn.execute("DELETE FROM gardens WHERE id = ?", (garden["id"],))
239# ---------------------------------------------------------------------------
240# Membership routes (spec §5.18)
241# ---------------------------------------------------------------------------
244@router.get("/{garden_slug_or_id}/members", response_model=list[GardenMemberRecord])
245def list_members(
246 garden_slug_or_id: str,
247 identity: Annotated[Identity, Depends(resolve_identity)],
248) -> list[GardenMemberRecord]:
249 """List members of a garden (Spec-02-Scopes-and-ACL). Requires any member role."""
250 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id)
251 if garden is None: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true
252 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="garden not found")
253 require_garden_read(garden, identity)
254 return _members_for_garden(garden["id"])
257@router.post(
258 "/{garden_slug_or_id}/members",
259 response_model=GardenMemberRecord,
260 status_code=status.HTTP_201_CREATED,
261)
262def add_member(
263 garden_slug_or_id: str,
264 req: GardenMemberRequest,
265 identity: Annotated[Identity, Depends(resolve_identity)],
266) -> GardenMemberRecord:
267 """Add a member to a garden (Spec-02-Scopes-and-ACL). Requires admin role."""
268 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id)
269 if garden is None: 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true
270 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="garden not found")
272 require_garden_admin(garden, identity)
274 existing_role = get_member_role(garden["id"], req.entity_uri)
275 if existing_role is not None:
276 raise HTTPException(
277 status_code=status.HTTP_409_CONFLICT,
278 detail=(
279 f"entity is already a member with role '{existing_role}'; use PATCH to change role"
280 ),
281 )
283 now = datetime.now(UTC).isoformat()
284 with db() as conn:
285 conn.execute(
286 """INSERT INTO garden_members (garden_id, entity_uri, role, added_by, added_at)
287 VALUES (?,?,?,?,?)""",
288 (garden["id"], req.entity_uri, req.role, identity.entity_uri, now),
289 )
291 return GardenMemberRecord(
292 entity_uri=req.entity_uri,
293 role=req.role,
294 added_by=identity.entity_uri,
295 added_at=now,
296 )
299@router.patch("/{garden_slug_or_id}/members/{entity_uri:path}", response_model=GardenMemberRecord)
300def update_member_role(
301 garden_slug_or_id: str,
302 entity_uri: str,
303 req: GardenMemberUpdateRequest,
304 identity: Annotated[Identity, Depends(resolve_identity)],
305) -> GardenMemberRecord:
306 """Change a member's role (Spec-02-Scopes-and-ACL). Requires admin."""
307 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id)
308 if garden is None: 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true
309 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="garden not found")
311 require_garden_admin(garden, identity)
313 existing_role = get_member_role(garden["id"], entity_uri)
314 if existing_role is None: 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true
315 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member not found")
317 if existing_role == "admin" and req.role != "admin":
318 _guard_last_admin(garden["id"], entity_uri)
320 with db() as conn:
321 conn.execute(
322 "UPDATE garden_members SET role = ? WHERE garden_id = ? AND entity_uri = ?",
323 (req.role, garden["id"], entity_uri),
324 )
325 row = conn.execute(
326 "SELECT * FROM garden_members WHERE garden_id = ? AND entity_uri = ?",
327 (garden["id"], entity_uri),
328 ).fetchone()
330 return GardenMemberRecord(
331 entity_uri=row["entity_uri"],
332 role=row["role"],
333 added_by=row["added_by"],
334 added_at=row["added_at"],
335 )
338@router.delete(
339 "/{garden_slug_or_id}/members/{entity_uri:path}",
340 status_code=status.HTTP_204_NO_CONTENT,
341)
342def remove_member(
343 garden_slug_or_id: str,
344 entity_uri: str,
345 identity: Annotated[Identity, Depends(resolve_identity)],
346) -> None:
347 """Remove a member from a garden (Spec-02-Scopes-and-ACL). Cannot remove last admin."""
348 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id)
349 if garden is None: 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true
350 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="garden not found")
352 require_garden_admin(garden, identity)
354 existing_role = get_member_role(garden["id"], entity_uri)
355 if existing_role is None: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true
356 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member not found")
358 if existing_role == "admin":
359 _guard_last_admin(garden["id"], entity_uri)
361 with db() as conn:
362 conn.execute(
363 "DELETE FROM garden_members WHERE garden_id = ? AND entity_uri = ?",
364 (garden["id"], entity_uri),
365 )
368def _guard_last_admin(garden_uuid: str, entity_uri: str) -> None:
369 """Raise 403 if removing/demoting entity_uri would leave the garden with no admins."""
370 with db() as conn:
371 admin_count: int = conn.execute(
372 "SELECT COUNT(*) FROM garden_members WHERE garden_id = ? AND role = 'admin'",
373 (garden_uuid,),
374 ).fetchone()[0]
375 if admin_count <= 1: 375 ↛ exitline 375 didn't return from function '_guard_last_admin' because the condition on line 375 was always true
376 raise HTTPException(
377 status_code=status.HTTP_403_FORBIDDEN,
378 detail="cannot remove or demote the last admin; promote another member first",
379 )
382def _guard_last_elevated_role(garden_uuid: str, entity_uri: str) -> None:
383 """For quarantine gardens: raise 409 if removal would leave no admin or moderator (§19.5.3)."""
384 with db() as conn:
385 elevated_count: int = conn.execute(
386 """SELECT COUNT(*) FROM garden_members
387 WHERE garden_id = ? AND role IN ('admin', 'quarantine:moderator')""",
388 (garden_uuid,),
389 ).fetchone()[0]
390 if elevated_count <= 1:
391 raise HTTPException(
392 status_code=status.HTTP_409_CONFLICT,
393 detail="quarantine garden must retain at least one admin or quarantine:moderator",
394 )
397# ---------------------------------------------------------------------------
398# Quarantine promote / reject (spec §5.25, §19.5.5)
399# ---------------------------------------------------------------------------
402@router.post("/{garden_slug_or_id}/promote", status_code=status.HTTP_200_OK)
403def promote_fact(
404 garden_slug_or_id: str,
405 req: QuarantinePromoteRequest,
406 identity: Annotated[Identity, Depends(resolve_identity)],
407) -> dict[str, Any]:
408 """Promote a quarantined fact to a target garden (Spec-08-Quarantine-Garden).
410 Requires quarantine:moderator or admin role.
411 """
412 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id)
413 if garden is None: 413 ↛ 414line 413 didn't jump to line 414 because the condition on line 413 was never true
414 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="garden not found")
416 if not garden.get("quarantine"):
417 raise HTTPException(
418 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
419 detail="this endpoint is only valid for quarantine gardens",
420 )
422 require_quarantine_moderator_or_admin(garden, identity)
424 now = datetime.now(UTC).isoformat()
426 with db() as conn:
427 fact_row = conn.execute(
428 """SELECT f.id, f.entity, f.relation,
429 COALESCE(fqs.quarantine_status, f.quarantine_status)
430 AS quarantine_status,
431 COALESCE(fqs.quarantine_garden_id, f.quarantine_garden_id)
432 AS quarantine_garden_id
433 FROM facts f
434 LEFT JOIN fact_quarantine_status fqs ON fqs.fact_id = f.id
435 WHERE f.id = ?""",
436 (req.fact_id,),
437 ).fetchone()
439 if fact_row is None or fact_row["quarantine_garden_id"] != garden["id"]:
440 raise HTTPException(
441 status_code=status.HTTP_404_NOT_FOUND,
442 detail="fact not found in quarantine garden",
443 )
445 if fact_row["quarantine_status"] != "pending":
446 raise HTTPException(
447 status_code=status.HTTP_409_CONFLICT,
448 detail="fact_not_quarantine_pending",
449 )
451 # Resolve target garden UUID if provided as slug
452 target_garden_db_id: str | None = None
453 if req.target_garden_id: 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true
454 tg = get_garden_by_slug_or_id(req.target_garden_id, tenant_id=identity.tenant_id)
455 if tg is None:
456 raise HTTPException(
457 status_code=status.HTTP_404_NOT_FOUND,
458 detail="target garden not found",
459 )
460 target_garden_db_id = tg["id"]
462 set_fact_garden_membership(
463 conn,
464 fact_id=req.fact_id,
465 garden_id=target_garden_db_id,
466 updated_by=identity.entity_uri,
467 )
468 set_fact_quarantine_status(
469 conn,
470 fact_id=req.fact_id,
471 quarantine_garden_id=garden["id"],
472 quarantine_status="promoted",
473 quarantine_reason=req.reason or "promoted",
474 quarantine_acted_by=identity.entity_uri,
475 quarantine_acted_at=now,
476 )
478 # Audit log (§19.5.6)
479 import uuid as _uuid
481 audit_id = str(_uuid.uuid4())
482 conn.execute(
483 "INSERT INTO fact_audit_log"
484 " (id, fact_id, event_type, entity_uri, oidc_sub, source,"
485 " attested_key_id, ts)"
486 " VALUES (?,?,?,?,?,?,?,?)",
487 (
488 audit_id,
489 req.fact_id,
490 "quarantine_promote",
491 identity.entity_uri,
492 identity.oidc_sub,
493 identity.entity_uri,
494 None,
495 now,
496 ),
497 )
498 emit_instruction_event_if_applicable(
499 INSTRUCTION_PROMOTED,
500 fact_id=req.fact_id,
501 fact_entity=fact_row["entity"],
502 fact_relation=fact_row["relation"],
503 actor_uri=identity.entity_uri,
504 tenant_id=identity.tenant_id,
505 oidc_sub=identity.oidc_sub,
506 source=identity.entity_uri,
507 detail={
508 "reason": req.reason or "promoted",
509 "quarantine_garden_id": garden["id"],
510 "target_garden_id": target_garden_db_id,
511 },
512 conn=conn,
513 )
515 return {
516 "fact_id": req.fact_id,
517 "action": "promoted",
518 "target_garden_id": target_garden_db_id,
519 "acted_by": identity.entity_uri,
520 "acted_at": now,
521 }
524@router.post("/{garden_slug_or_id}/reject", status_code=status.HTTP_200_OK)
525def reject_fact(
526 garden_slug_or_id: str,
527 req: QuarantineRejectRequest,
528 identity: Annotated[Identity, Depends(resolve_identity)],
529) -> dict[str, Any]:
530 """Reject a quarantined fact (Spec-08-Quarantine-Garden).
532 Sets confidence = 0.0 and quarantine_status = 'rejected'.
533 Requires quarantine:moderator or admin role.
534 """
535 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id)
536 if garden is None: 536 ↛ 537line 536 didn't jump to line 537 because the condition on line 536 was never true
537 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="garden not found")
539 if not garden.get("quarantine"): 539 ↛ 540line 539 didn't jump to line 540 because the condition on line 539 was never true
540 raise HTTPException(
541 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
542 detail="this endpoint is only valid for quarantine gardens",
543 )
545 require_quarantine_moderator_or_admin(garden, identity)
547 now = datetime.now(UTC).isoformat()
549 with db() as conn:
550 fact_row = conn.execute(
551 """SELECT f.id,
552 COALESCE(fqs.quarantine_status, f.quarantine_status)
553 AS quarantine_status,
554 COALESCE(fqs.quarantine_garden_id, f.quarantine_garden_id)
555 AS quarantine_garden_id
556 FROM facts f
557 LEFT JOIN fact_quarantine_status fqs ON fqs.fact_id = f.id
558 WHERE f.id = ?""",
559 (req.fact_id,),
560 ).fetchone()
562 if fact_row is None or fact_row["quarantine_garden_id"] != garden["id"]: 562 ↛ 563line 562 didn't jump to line 563 because the condition on line 562 was never true
563 raise HTTPException(
564 status_code=status.HTTP_404_NOT_FOUND,
565 detail="fact not found in quarantine garden",
566 )
568 if fact_row["quarantine_status"] != "pending": 568 ↛ 569line 568 didn't jump to line 569 because the condition on line 568 was never true
569 raise HTTPException(
570 status_code=status.HTTP_409_CONFLICT,
571 detail="fact_not_quarantine_pending",
572 )
574 set_fact_validity_override(
575 conn,
576 fact_id=req.fact_id,
577 confidence=0.0,
578 reason=req.reason or "rejected",
579 updated_by=identity.entity_uri,
580 )
581 set_fact_quarantine_status(
582 conn,
583 fact_id=req.fact_id,
584 quarantine_garden_id=garden["id"],
585 quarantine_status="rejected",
586 quarantine_reason=req.reason or "rejected",
587 quarantine_acted_by=identity.entity_uri,
588 quarantine_acted_at=now,
589 )
591 # Append-only retraction log (§24.2.1 c.3)
592 conn.execute(
593 "INSERT INTO fact_retractions"
594 " (id, fact_id, retracted_at, retracted_by) VALUES (?,?,?,?)",
595 (str(uuid.uuid4()), req.fact_id, now, identity.entity_uri),
596 )
598 # Audit log (§19.5.6)
599 import uuid as _uuid
601 audit_id = str(_uuid.uuid4())
602 conn.execute(
603 "INSERT INTO fact_audit_log"
604 " (id, fact_id, event_type, entity_uri, oidc_sub, source,"
605 " attested_key_id, ts)"
606 " VALUES (?,?,?,?,?,?,?,?)",
607 (
608 audit_id,
609 req.fact_id,
610 "quarantine_reject",
611 identity.entity_uri,
612 identity.oidc_sub,
613 identity.entity_uri,
614 None,
615 now,
616 ),
617 )
619 return {
620 "fact_id": req.fact_id,
621 "action": "rejected",
622 "acted_by": identity.entity_uri,
623 "acted_at": now,
624 }