Skip to content

Commit b84a893

Browse files
Sbussisoclaude
andcommitted
fix(gdpr): lift paid-plan gate on Full Organization Reset (Article 17)
Pre-fix: the only self-serve right-to-erasure path in the dashboard was the "Reset Everything" button under Settings → Danger Zone, and it was gated behind `_require_active_paid_plan(user, db)` on the backend + admin-feature check on the frontend. Free-tier admins who wanted to exercise their GDPR Article 17 right had no in-app path — they had to file a GitHub issue and wait for operator intervention. /legal/privacy §6 explicitly promised the action was self-serve, so the gate was both a compliance hazard and a contradiction of our own published policy. Right-to-erasure is a legal obligation we cannot gate behind a paid subscription. Lifted the gate. Done split, not blanket-lift: the sibling `/settings/danger/wipe-logs` endpoint is operator-convenience (selective stream + MCP-activity log purge while keeping nodes/cameras/settings intact) — that's not a right-to-erasure operation and stays Pro / Pro Plus. Changes: Backend - `app/api/cameras.py::full_reset` — removed the `_require_active_paid_plan(user, db)` call. Docstring rewritten to explicitly call out the GDPR Article 17 framing + why the gate was wrong + what stays paid-only (wipe-logs). - `app/api/cameras.py::wipe_stream_logs` — docstring tightened to explain why this one IS still paid-only and to point at full-reset as the Free-tier erasure path. - `tests/test_cameras.py` — flipped the negative test: `test_full_reset_requires_paid_plan_in_db_not_just_jwt` (expected 403 on free_org) replaced with `test_full_reset_works_on_free_plan` (expected 200 on free_org). Companion `…_works_on_pro_plus_too` pins the happy path didn't regress. - `tests/test_gdpr.py::test_full_reset_clears_every_org_scoped_table` — dropped the `_require_active_paid_plan` monkeypatch; the helper no longer needs stubbing because it's no longer called. Frontend - `SettingsPage.jsx` — per-item gating instead of section-level: Full Organization Reset is always shown (with a paragraph explicitly identifying it as the GDPR Article 17 right-to-erasure action); Wipe All Logs is locked behind a 🔒 Pro / Pro Plus badge + Upgrade button for free-tier admins. - `index.css` — added `.danger-item-locked` + `.plan-locked-badge` rules for the per-item lock state. Kept legacy `.danger-locked` class for back-compat. - `LegalPage.jsx` §6 — added explicit "Reset Everything is available on every plan, including Free" sentence so the privacy policy matches the now-actual behaviour. Docs - `docs/Plans.jsx` — split the "Danger-zone tools" row into two: Full reset = Yes/Yes/Yes (every plan), Selective log wipe = —/Yes/Yes (Pro+). - `docs/Dashboard.jsx` — Danger Zone bullet rewritten with the same split. - `AGENTS.md` — API Routes table for both endpoints now spells out the plan tier ("every plan" vs "Pro/Pro Plus") + the framing rationale. Refreshed `backend/static/` from a clean build. 612 backend tests pass; frontend build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent faf4300 commit b84a893

16 files changed

Lines changed: 143 additions & 62 deletions

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,8 +338,8 @@ Validation constants (also in `models.py`):
338338
- `GET /settings/motion-ingestion` — read motion-event ingestion toggle (admin)
339339
- `POST /settings/motion-ingestion` — toggle motion-event ingestion org-wide (admin, 30/min)
340340
- `GET /audit-logs` — audit logs (admin, 120/min)
341-
- `POST /settings/danger/wipe-logs`permanently delete all stream + MCP + audit logs (admin + Pro/Pro Plus, 5/hour)
342-
- `POST /settings/danger/full-reset` — wipe all nodes/cameras/logs/settings for the org (admin + Pro/Pro Plus, 3/hour)
341+
- `POST /settings/danger/wipe-logs`selectively delete stream + MCP activity logs while keeping the org running (admin + **Pro/Pro Plus**, 5/hour). Operator-convenience feature, *not* a right-to-erasure obligation.
342+
- `POST /settings/danger/full-reset`GDPR Article 17 right-to-erasure: wipe all nodes/cameras/recordings/snapshots/incidents/logs/settings for the org (admin, **every plan**, 3/hour). Routes through the shared `app.core.gdpr.delete_org_data` helper so this end-state matches what `organization.deleted` Clerk webhook produces.
343343

344344
**nodes.py** (prefix `/api/nodes`):
345345
- `POST /validate` — validate node_id + API key pair, used by CloudNode setup wizard (10/min)

backend/app/api/cameras.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -785,7 +785,18 @@ async def wipe_stream_logs(
785785
user: AuthUser = Depends(require_admin),
786786
db: Session = Depends(get_db),
787787
):
788-
"""Permanently delete all stream access logs for this organization."""
788+
"""Permanently delete all stream access + MCP activity logs for
789+
this organization.
790+
791+
Plan gate: Pro / Pro Plus. This is operator-convenience —
792+
selective audit-log hygiene that keeps the org otherwise running.
793+
It is **not** the GDPR right-to-erasure endpoint (that's
794+
``/full-reset`` below, which is available on every plan); a Free
795+
-tier customer who wants to purge their stream-access history
796+
can always use Full Reset and re-add their cameras after. The
797+
paid gate here is the same JWT + DB double-check pattern used
798+
on every Pro-only operation; see ``_require_active_paid_plan``.
799+
"""
789800
_require_active_paid_plan(user, db)
790801
from app.models import StreamAccessLog
791802

@@ -817,7 +828,7 @@ async def full_reset(
817828
db: Session = Depends(get_db),
818829
):
819830
"""
820-
Full organization reset.
831+
Full organization reset — the GDPR Article 17 right-to-erasure path.
821832
822833
Behaviour:
823834
1. Send each CloudNode a ``wipe_data`` command so the per-node
@@ -835,8 +846,18 @@ async def full_reset(
835846
events, notifications, incidents, email logs, monthly usage,
836847
etc.) silently persisted, which was both an Article 17 violation
837848
and a quiet way for stale data to leak across cancellations.
849+
850+
Plan gate: **none**. Every plan (Free included) can self-serve
851+
full erasure of their organization's data — this is the GDPR
852+
Article 17 right-to-erasure path, which is a legal requirement
853+
we cannot gate behind a paid plan. The sibling ``wipe-logs``
854+
endpoint *is* still paid-only because it's an operator-convenience
855+
feature (selective audit-log hygiene that keeps the org running),
856+
not a right-to-erasure obligation. Admin-only via
857+
``require_admin``, typed-confirmation in the UI, audit-logged
858+
before the cascade runs, and rate-limited to 3/hour so a
859+
runaway script can't repeatedly nuke an org by accident.
838860
"""
839-
_require_active_paid_plan(user, db)
840861
from app.api.hls import cleanup_camera_cache
841862
from app.api.ws import manager
842863
from app.core.gdpr import delete_org_data

backend/static/assets/DocsPage-D8wIfef_.js renamed to backend/static/assets/DocsPage-DsI8I6A5.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/static/assets/LegalPage-Crmyp3a3.js renamed to backend/static/assets/LegalPage-vtV2hTXu.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/static/assets/SettingsPage-DfQVeiJY.js renamed to backend/static/assets/SettingsPage-C0wxD8X7.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/static/assets/index-Chpn3Q6c.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/static/assets/index-DWf8tzZp.css

Lines changed: 0 additions & 1 deletion
This file was deleted.

backend/static/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
<meta name="twitter:image:alt" content="A close-up of a USB security webcam with a green LED indicator, set against a dark background with green and purple dashboard bokeh." />
2525

2626
<title>Sentinel by SourceBox — Private Security Camera System</title>
27-
<script type="module" crossorigin src="/assets/index-DEzyi58d.js"></script>
27+
<script type="module" crossorigin src="/assets/index-BpnzKESq.js"></script>
2828
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-Bg_NI1en.js">
2929
<link rel="modulepreload" crossorigin href="/assets/preload-helper-D4M6sveU.js">
3030
<link rel="modulepreload" crossorigin href="/assets/dist-DoOXorMg.js">
@@ -33,7 +33,7 @@
3333
<link rel="modulepreload" crossorigin href="/assets/usePlanInfo-CHbYbInD.js">
3434
<link rel="modulepreload" crossorigin href="/assets/useSharedToken-6h071G3c.js">
3535
<link rel="modulepreload" crossorigin href="/assets/useToasts-By-oVKTA.js">
36-
<link rel="stylesheet" crossorigin href="/assets/index-DWf8tzZp.css">
36+
<link rel="stylesheet" crossorigin href="/assets/index-Chpn3Q6c.css">
3737
</head>
3838
<body>
3939
<div id="root"></div>

backend/tests/test_cameras.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -440,20 +440,31 @@ def test_wipe_logs_succeeds_when_db_plan_is_paid(admin_client, monkeypatch):
440440
assert "deleted_logs" in body
441441

442442

443-
def test_full_reset_requires_paid_plan_in_db_not_just_jwt(
444-
admin_client, monkeypatch
445-
):
446-
"""Same exploit window as wipe-logs but for full_reset, which is
447-
the more destructive of the two — wipes nodes, cameras, settings,
448-
audit log."""
443+
def test_full_reset_works_on_free_plan(admin_client, monkeypatch):
444+
"""Full reset is the GDPR Article 17 right-to-erasure path and is
445+
NOT gated on plan tier — every plan (Free included) can self-serve
446+
full erasure of their organization's data.
447+
448+
Pins the negative direction of the previous test (which required a
449+
paid plan): downgrading to Free must NOT block the customer from
450+
deleting their data. The legal obligation outranks the SaaS plan
451+
tier. Sibling ``wipe-logs`` endpoint is still paid-only because
452+
it's selective audit hygiene, not erasure — see
453+
test_wipe_logs_requires_paid_plan_in_db_not_just_jwt above.
454+
"""
449455
_force_org_plan(monkeypatch, "free_org")
450456

451457
resp = admin_client.post("/api/settings/danger/full-reset")
452-
assert resp.status_code == 403
453-
assert "paid" in resp.json()["detail"].lower()
458+
assert resp.status_code == 200
459+
body = resp.json()
460+
assert body["success"] is True
461+
assert "nodes_deleted" in body
454462

455463

456-
def test_full_reset_succeeds_when_db_plan_is_paid(admin_client, monkeypatch):
464+
def test_full_reset_works_on_pro_plus_too(admin_client, monkeypatch):
465+
"""Mirror of the free-plan test — same endpoint, paid plan, same
466+
success shape. Pins that lifting the gate didn't break the
467+
happy-path-for-paid customer."""
457468
_force_org_plan(monkeypatch, "pro_plus")
458469

459470
resp = admin_client.post("/api/settings/danger/full-reset")

0 commit comments

Comments
 (0)