Skip to content

Commit 9056fa3

Browse files
Sbussisoclaude
andcommitted
sentinel: slice 1 — wire UI to real backend (config + run history persistence)
The v3 SentinelPage shipped as a polished but local-state-only mock. This commit lands "slice 1" of the 7-slice Sentinel rollout: per-org configuration and run history now persist via real backend tables + API routes. The agent itself still doesn't exist (that's slice 3), so run history is empty for every org until the agent service starts producing rows. Backend: - New SentinelConfig model (one row per org) with the 9 config fields the UI mutates: enabled, motion_enabled, incident_opened_enabled, motion_cooldown_min, schedule_mode, schedule_start, schedule_end, active_days (JSON), camera_scope (JSON). JSON columns stored as TEXT and (de)serialised via helper methods on the model — matches the McpApiKey.scope_tools / Notification.meta_json pattern. - New SentinelRun model with composite (org_id, triggered_at) index defined on the model from day one — sync_schema doesn't add indexes after the fact, so this MUST land at table-create time. tool_trace stored as JSON list, truncated to last 50 entries via set_tool_trace() to keep row sizes bounded once the agent starts producing real traces. - New app/api/sentinel.py router with 4 endpoints: GET /api/sentinel/config fetch (creates default row on first call; returns plan_gated flag for non-Pro-Plus orgs so read-only UI renders) PATCH /api/sentinel/config partial update (Pro Plus only; 402 otherwise; audit row written) GET /api/sentinel/runs paginated list + inline stats (runs_today / runs_total / incidents_filed) GET /api/sentinel/runs/{id} single run with tool trace - Plan resolution via effective_plan_for_caps() so past-due grace is respected; PATCH semantics with model_dump(exclude_unset=True) match the email-prefs handler at notifications.py:1080-1108 (avoids stale-clobber from concurrent tabs). - Field-level validation: schedule_mode constrained to {always, scheduled, off}; HH:MM time format checked; active_days filtered against the 7-day key set. Frontend: - New api.js helpers: getSentinelConfig, updateSentinelConfig, getSentinelRuns, getSentinelRun. - SentinelPage.jsx rewired top-to-bottom: - Drop MOCK_CAMERAS, MOCK_RUNS, MOCK_TOOL_TRACE arrays. - useEffect on mount loads config + cameras + runs in parallel (independent fetches; UI streams in instead of blocking on the slowest). - All toggle/input handlers funnel through patchConfig() which optimistically updates local state then PATCHes the server, rolling back + toasting on error (matches SettingsPage pattern). - When config.plan_gated is true: render a plan-gate banner across the top, all controls disabled, PATCH calls skipped client-side. - Empty states everywhere: timeline panel, history table, scope grid (when no cameras connected) — all show clean messaging pointing the user at the next step. - "Run now" button rendered disabled with title-attribute tooltip until slice 4 lands. - Run-detail drawer fetches the full row + tool_trace on click, shows graceful loading state. Falls through to outcome-only when the trace isn't populated yet. - New CSS for: loading state, empty state, plan-gate banner with gradient bg + Pro Plus pill + upgrade CTA, disabled run-now styling. Verified with a local boot: $ python -c "from app.main import app" → all 4 sentinel routes registered $ python -c "Base.metadata.create_all(:memory: engine)" → tables + composite (org_id, triggered_at) index land cleanly Out of scope for this slice (deferred to later commits): Slice 2: webhook notification channel Slice 3: real LLM agent service producing runs Slice 4: manual "Run now" dispatch Slice 5: monthly cap enforcement at 300 runs Slice 6: cron-driven scheduled sweeps Slice 7: real agent reasoning trace in drawer Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ef99e5c commit 9056fa3

6 files changed

Lines changed: 1207 additions & 457 deletions

File tree

backend/app/api/sentinel.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
"""
2+
Sentinel API — config + run history for the autonomous security agent.
3+
4+
Slice 1 of the Sentinel rollout: this module covers persistence only.
5+
The agent itself isn't wired up yet — `sentinel_runs` rows will only
6+
start appearing once slice 3 ships. See plans/ for the 7-slice
7+
roadmap.
8+
9+
Endpoints:
10+
- GET /api/sentinel/config fetch (creates default row on first call)
11+
- PATCH /api/sentinel/config partial update (PRO PLUS only)
12+
- GET /api/sentinel/runs paginated run history + small stats
13+
- GET /api/sentinel/runs/{id} single run detail with tool trace
14+
15+
Plan gating:
16+
- GET endpoints return 200 with `plan_gated: true` for non-Pro-Plus
17+
orgs so the read-only UI can render.
18+
- PATCH returns 402 for non-Pro-Plus orgs (write requires plan).
19+
20+
Pattern notes:
21+
- PATCH semantics with `exclude_unset=True` mirror the email-prefs
22+
pattern at notifications.py:1080-1108 — partial updates are the
23+
norm, frontend toggles fire one at a time, no stale-clobber.
24+
- Plan resolution via `effective_plan_for_caps()` — JWT claims can
25+
be stale; this respects past-due grace.
26+
- Audit row written on every PATCH so admin actions are traceable
27+
(matches email-prefs at notifications.py:1110).
28+
"""
29+
30+
import logging
31+
import uuid
32+
from datetime import UTC, datetime
33+
from typing import Optional
34+
35+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
36+
from pydantic import BaseModel, Field
37+
from sqlalchemy.orm import Session
38+
39+
from app.core.audit import write_audit
40+
from app.core.auth import AuthUser, require_admin, require_view
41+
from app.core.database import get_db
42+
from app.core.plans import effective_plan_for_caps, get_plan_display_name
43+
from app.models.models import SentinelConfig, SentinelRun
44+
45+
logger = logging.getLogger(__name__)
46+
router = APIRouter(prefix="/api/sentinel", tags=["sentinel"])
47+
48+
49+
# ── PATCH body model ────────────────────────────────────────────────
50+
class SentinelConfigPatch(BaseModel):
51+
enabled: Optional[bool] = None
52+
motion_enabled: Optional[bool] = None
53+
incident_opened_enabled: Optional[bool] = None
54+
motion_cooldown_min: Optional[int] = Field(None, ge=1, le=60)
55+
schedule_mode: Optional[str] = None # validated below
56+
schedule_start: Optional[str] = None # HH:MM
57+
schedule_end: Optional[str] = None # HH:MM
58+
active_days: Optional[list[str]] = None
59+
camera_scope: Optional[dict] = None
60+
61+
62+
_VALID_SCHEDULE_MODES = {"always", "scheduled", "off"}
63+
_VALID_DAY_KEYS = {"mon", "tue", "wed", "thu", "fri", "sat", "sun"}
64+
65+
66+
def _is_pro_plus(db: Session, org_id: str) -> bool:
67+
return effective_plan_for_caps(db, org_id) == "pro_plus"
68+
69+
70+
def _ensure_config_row(db: Session, org_id: str) -> SentinelConfig:
71+
"""Get-or-create the per-org Sentinel config row.
72+
73+
Lazy-create on first GET is fine here — the unique index on
74+
`org_id` means a concurrent INSERT race resolves cleanly via
75+
IntegrityError (we then re-query and return the row that won).
76+
"""
77+
cfg = db.query(SentinelConfig).filter_by(org_id=org_id).first()
78+
if cfg is not None:
79+
return cfg
80+
cfg = SentinelConfig(org_id=org_id)
81+
db.add(cfg)
82+
try:
83+
db.commit()
84+
db.refresh(cfg)
85+
except Exception: # IntegrityError on race — re-fetch the winner
86+
db.rollback()
87+
cfg = db.query(SentinelConfig).filter_by(org_id=org_id).first()
88+
if cfg is None:
89+
raise
90+
return cfg
91+
92+
93+
def _validate_hhmm(value: str, field_name: str) -> None:
94+
"""Reject anything that isn't `HH:MM` 0–23:0–59."""
95+
if not value or len(value) != 5 or value[2] != ":":
96+
raise HTTPException(400, f"{field_name} must be HH:MM")
97+
try:
98+
h = int(value[:2])
99+
m = int(value[3:])
100+
except ValueError:
101+
raise HTTPException(400, f"{field_name} must be HH:MM")
102+
if not (0 <= h <= 23 and 0 <= m <= 59):
103+
raise HTTPException(400, f"{field_name} out of range")
104+
105+
106+
# ── GET /api/sentinel/config ────────────────────────────────────────
107+
@router.get("/config")
108+
async def get_config(
109+
user: AuthUser = Depends(require_view),
110+
db: Session = Depends(get_db),
111+
):
112+
"""Return the org's Sentinel config (creating defaults on first call).
113+
114+
Always returns 200 — non-Pro-Plus orgs get the same payload with
115+
`plan_gated: true` so the frontend can render a read-only view
116+
with a clear upgrade banner.
117+
"""
118+
cfg = _ensure_config_row(db, user.org_id)
119+
plan = effective_plan_for_caps(db, user.org_id)
120+
return {
121+
"config": cfg.to_dict(),
122+
"plan_gated": plan != "pro_plus",
123+
"plan_required": "pro_plus",
124+
"plan_current": get_plan_display_name(plan),
125+
}
126+
127+
128+
# ── PATCH /api/sentinel/config ──────────────────────────────────────
129+
@router.patch("/config")
130+
async def patch_config(
131+
request: Request,
132+
patch: SentinelConfigPatch,
133+
user: AuthUser = Depends(require_admin),
134+
db: Session = Depends(get_db),
135+
):
136+
"""Apply a partial update to the org's Sentinel config.
137+
138+
Only fields present in the request body are touched — partial
139+
updates are the norm (frontend toggles fire one at a time).
140+
Returns the full config so the frontend doesn't need a follow-up
141+
GET to reflect the new state.
142+
"""
143+
if not _is_pro_plus(db, user.org_id):
144+
raise HTTPException(
145+
status_code=402,
146+
detail={"error": "plan_required", "plan": "pro_plus"},
147+
)
148+
149+
cfg = _ensure_config_row(db, user.org_id)
150+
changes: list[str] = []
151+
152+
body = patch.model_dump(exclude_unset=True)
153+
for field, value in body.items():
154+
if value is None:
155+
continue
156+
157+
# Field-level validation for the constrained values.
158+
if field == "schedule_mode":
159+
if value not in _VALID_SCHEDULE_MODES:
160+
raise HTTPException(400, f"invalid schedule_mode: {value!r}")
161+
cfg.schedule_mode = value
162+
elif field == "schedule_start":
163+
_validate_hhmm(value, "schedule_start")
164+
cfg.schedule_start = value
165+
elif field == "schedule_end":
166+
_validate_hhmm(value, "schedule_end")
167+
cfg.schedule_end = value
168+
elif field == "active_days":
169+
if not isinstance(value, list):
170+
raise HTTPException(400, "active_days must be a list")
171+
cleaned = [d for d in value if d in _VALID_DAY_KEYS]
172+
cfg.set_active_days(cleaned)
173+
elif field == "camera_scope":
174+
if not isinstance(value, dict):
175+
raise HTTPException(400, "camera_scope must be an object")
176+
cfg.set_camera_scope(value)
177+
else:
178+
# Boolean / int columns — set directly
179+
setattr(cfg, field, value)
180+
181+
changes.append(f"{field}={value}")
182+
183+
if changes:
184+
cfg.updated_at = datetime.now(tz=UTC).replace(tzinfo=None)
185+
db.commit()
186+
db.refresh(cfg)
187+
write_audit(
188+
db,
189+
org_id=user.org_id,
190+
event="sentinel_config_updated",
191+
user_id=user.user_id,
192+
username=user.email or user.username,
193+
details=", ".join(changes),
194+
request=request,
195+
)
196+
197+
return {"config": cfg.to_dict()}
198+
199+
200+
# ── GET /api/sentinel/runs ──────────────────────────────────────────
201+
@router.get("/runs")
202+
async def list_runs(
203+
limit: int = Query(50, ge=1, le=200),
204+
offset: int = Query(0, ge=0),
205+
trigger: Optional[str] = Query(None, description="filter: motion|incident_opened|manual|scheduled"),
206+
since: Optional[str] = Query(None, description="ISO datetime — runs >= this"),
207+
user: AuthUser = Depends(require_view),
208+
db: Session = Depends(get_db),
209+
):
210+
"""List Sentinel runs for the user's org with offset+limit pagination
211+
and small inline stats (runs_today, total).
212+
213+
No SSE for live updates yet — slice 4 will add a stream endpoint
214+
when the agent service starts producing rows.
215+
"""
216+
base = db.query(SentinelRun).filter_by(org_id=user.org_id)
217+
218+
q = base
219+
if trigger:
220+
q = q.filter_by(trigger_type=trigger)
221+
if since:
222+
try:
223+
since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
224+
# Strip tz to match the naive datetimes stored in the column.
225+
if since_dt.tzinfo is not None:
226+
since_dt = since_dt.replace(tzinfo=None)
227+
q = q.filter(SentinelRun.triggered_at >= since_dt)
228+
except ValueError:
229+
raise HTTPException(400, "invalid `since` — expected ISO datetime")
230+
231+
total = q.count()
232+
rows = (
233+
q.order_by(SentinelRun.triggered_at.desc())
234+
.offset(offset)
235+
.limit(limit)
236+
.all()
237+
)
238+
239+
# Small inline stats — avoids a separate /usage endpoint while
240+
# the cap framework isn't built yet (slice 5).
241+
today_start = datetime.now(tz=UTC).replace(
242+
hour=0, minute=0, second=0, microsecond=0, tzinfo=None
243+
)
244+
runs_today = (
245+
base.filter(SentinelRun.triggered_at >= today_start).count()
246+
)
247+
248+
incident_count = base.filter(SentinelRun.outcome == "incident").count()
249+
250+
return {
251+
"runs": [r.to_dict(include_trace=False) for r in rows],
252+
"total": total,
253+
"stats": {
254+
"runs_today": runs_today,
255+
"runs_total": base.count(),
256+
"incidents_filed": incident_count,
257+
},
258+
}
259+
260+
261+
# ── GET /api/sentinel/runs/{run_id} ─────────────────────────────────
262+
@router.get("/runs/{run_id}")
263+
async def get_run(
264+
run_id: str,
265+
user: AuthUser = Depends(require_view),
266+
db: Session = Depends(get_db),
267+
):
268+
"""Return a single run with full tool trace (for the drawer)."""
269+
row = (
270+
db.query(SentinelRun)
271+
.filter_by(org_id=user.org_id, id=run_id)
272+
.first()
273+
)
274+
if row is None:
275+
raise HTTPException(404, "run not found")
276+
return row.to_dict(include_trace=True)

backend/app/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
motion,
3737
nodes,
3838
notifications,
39+
sentinel,
3940
webhooks,
4041
well_known,
4142
ws,
@@ -453,6 +454,9 @@ async def security_headers(request: Request, call_next):
453454
app.include_router(incidents.router)
454455
app.include_router(motion.router)
455456
app.include_router(notifications.router)
457+
# Sentinel agent — config + run history. Slice 1 of the 7-slice
458+
# rollout: persistence only, the agent itself isn't yet wired up.
459+
app.include_router(sentinel.router)
456460
# GDPR Article 20 export endpoint. Article 17 erasure is served
457461
# by the existing /api/settings/danger/full-reset endpoint, which
458462
# now routes through app.core.gdpr.delete_org_data so both paths

0 commit comments

Comments
 (0)