Skip to content

Commit 1a969f4

Browse files
Sbussisoclaude
andcommitted
ops(motion): per-org server-side kill switch for motion-event ingestion
Closes the motion-runaway scenario from yesterday's SaaS-readiness review: there was no way to stop a misbehaving / mis-configured sensor from flooding events into MotionEvent without reaching the node itself. The per-camera recording policy is the granularity operators usually want, but if a sensor is producing hundreds of events per second it's faster to flip a server-side switch than to SSH to the node and reconfigure FFmpeg's scene-change threshold. Changes: - hls.py POST /motion: short-circuit before recording when Setting `motion_ingestion_enabled` is "false" for the camera's org. Returns 200 + ingested:false rather than 4xx so the CloudNode treats this as a by-design rejection (same shape as the plan-cap suspension path) and doesn't burn its retry budget. Default "true" so orgs that never touch the toggle keep the original always-ingest behaviour. - cameras.py: new GET + POST /api/settings/motion-ingestion (admin for write, viewer for read). Mirrors the notifications/timezone settings pattern: stored in the existing Setting key/value table, no schema migration needed. Audited so there's a record of who flipped it. - 3 new tests pinning the kill-switch shape: default enabled, toggle persists across GET, viewer can't flip the switch. This is the one of the cheap fixes from the SaaS-readiness review. Costs ~30 lines of code, gives operators an immediate stop-bleeding lever for a real failure mode. 290 backend tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 17e12a7 commit 1a969f4

3 files changed

Lines changed: 98 additions & 1 deletion

File tree

backend/app/api/cameras.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,57 @@ async def get_notification_settings(
436436
}
437437

438438

439+
@router.get("/settings/motion-ingestion")
440+
async def get_motion_ingestion_setting(
441+
user: AuthUser = Depends(require_view), db: Session = Depends(get_db)
442+
):
443+
"""Return whether server-side motion-event ingestion is enabled
444+
for this org.
445+
446+
Defaults to enabled — the kill switch only takes effect after an
447+
admin explicitly flips it off via POST. This is a safety valve
448+
for runaway sensors flooding events; under normal operation it
449+
stays on and the per-camera recording policy is the granularity
450+
operators usually want.
451+
"""
452+
enabled = Setting.get(db, user.org_id, "motion_ingestion_enabled", "true").lower() == "true"
453+
return {"motion_ingestion_enabled": enabled}
454+
455+
456+
@router.post("/settings/motion-ingestion")
457+
@limiter.limit("30/minute")
458+
async def update_motion_ingestion_setting(
459+
request: Request,
460+
user: AuthUser = Depends(require_admin),
461+
db: Session = Depends(get_db),
462+
):
463+
"""Toggle server-side motion-event ingestion for the org.
464+
465+
Body: ``{"enabled": true|false}``.
466+
467+
When set to false, ``POST /api/cameras/{id}/motion`` short-circuits
468+
with ``{"ingested": false}`` and no MotionEvent rows are written.
469+
Stops a misbehaving / mis-configured camera or node from flooding
470+
the events table without requiring physical access to the node.
471+
Audited so there's a record of who flipped it.
472+
"""
473+
payload = await request.json()
474+
enabled = bool(payload.get("enabled"))
475+
Setting.set(
476+
db, user.org_id, "motion_ingestion_enabled", "true" if enabled else "false"
477+
)
478+
write_audit(
479+
db,
480+
org_id=user.org_id,
481+
event="motion_ingestion_toggled",
482+
user_id=user.user_id,
483+
username=audit_label(user),
484+
details={"enabled": enabled},
485+
request=request,
486+
)
487+
return {"motion_ingestion_enabled": enabled}
488+
489+
439490
@router.post("/settings/notifications")
440491
@limiter.limit("30/minute")
441492
async def update_notification_settings(

backend/app/api/hls.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from app.core.auth import get_current_user
1414
from app.core.limiter import limiter
1515
from app.models import Camera, CameraNode, StreamAccessLog
16+
from app.models.models import Setting
1617
from app.models.models import OrgMonthlyUsage
1718

1819
router = APIRouter(prefix="/api/cameras/{camera_id}", tags=["streaming"])
@@ -708,6 +709,17 @@ async def push_motion_event(
708709
if not camera:
709710
raise HTTPException(status_code=404, detail="Camera not found")
710711

712+
# Per-org kill switch. When an admin disables ingestion (e.g. a
713+
# misbehaving sensor is flooding events and you need a server-side
714+
# stop without reaching the node), short-circuit before recording
715+
# anything. Returns 200 + ingested:false so the CloudNode treats
716+
# this as a successful "by design" rejection and doesn't burn its
717+
# retry budget — same behaviour as the plan-cap suspension path.
718+
# Default "true" so orgs that never touch the toggle keep the
719+
# original always-ingest behaviour.
720+
if Setting.get(db, node.org_id, "motion_ingestion_enabled", "true").lower() != "true":
721+
return {"success": True, "ingested": False, "reason": "ingestion_disabled"}
722+
711723
body = await request.json()
712724

713725
from app.api.ws import _handle_motion_event
@@ -723,4 +735,4 @@ async def push_motion_event(
723735
},
724736
)
725737

726-
return {"success": True}
738+
return {"success": True, "ingested": True}

backend/tests/test_cameras.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,40 @@ def test_notification_settings_reflected_in_all_settings(admin_client):
8787
assert resp.json()["notifications"]["motion_notifications"] is False
8888

8989

90+
def test_get_motion_ingestion_default_enabled(admin_client):
91+
"""Default is on — kill switch only filters after explicit opt-out.
92+
Critical to verify because flipping the default would silently
93+
disable motion ingestion on every existing org."""
94+
resp = admin_client.get("/api/settings/motion-ingestion")
95+
assert resp.status_code == 200
96+
assert resp.json() == {"motion_ingestion_enabled": True}
97+
98+
99+
def test_motion_ingestion_toggle_persists(admin_client):
100+
"""Admin disables, GET reflects, admin re-enables, GET reflects."""
101+
off = admin_client.post("/api/settings/motion-ingestion", json={"enabled": False})
102+
assert off.status_code == 200
103+
assert off.json()["motion_ingestion_enabled"] is False
104+
105+
follow = admin_client.get("/api/settings/motion-ingestion")
106+
assert follow.json()["motion_ingestion_enabled"] is False
107+
108+
on = admin_client.post("/api/settings/motion-ingestion", json={"enabled": True})
109+
assert on.json()["motion_ingestion_enabled"] is True
110+
111+
follow_on = admin_client.get("/api/settings/motion-ingestion")
112+
assert follow_on.json()["motion_ingestion_enabled"] is True
113+
114+
115+
def test_motion_ingestion_toggle_requires_admin(viewer_client):
116+
"""Members without admin can't flip the kill switch."""
117+
resp = viewer_client.post(
118+
"/api/settings/motion-ingestion", json={"enabled": False}
119+
)
120+
# Same rejection shape as other admin-gated settings endpoints.
121+
assert resp.status_code in (401, 403, 404)
122+
123+
90124
def test_update_notification_settings_requires_admin(viewer_client):
91125
"""Non-admin members can't flip the toggles."""
92126
resp = viewer_client.post("/api/settings/notifications", json={

0 commit comments

Comments
 (0)