diff --git a/CHANGELOG.md b/CHANGELOG.md index ce2e4af..c0a42c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ the first consumer-visible behaviour change and will drive the next SDK version - **Automation-detector wording**: the anti-tamper flag now reads "Anti-tamper signals" (not "Automation detected") when the combined confidence is weak — e.g. devtools open in a dev environment — so a human isn't labelled a bot. The machine-readable `code` (`automation_suspected`) is unchanged; the reason text also drops the `tamper.` prefix for readability. ### Internal +- **Data-subject endpoints** ([ADR-0004](docs/adr/0004-consent-and-data-lifecycle.md)): `DELETE /v1/identity/:id` (GDPR Art. 17 — erases the identity; snapshots/drifts/risk assessments/account links cascade) and `GET /v1/identity/:id/export` (Art. 20 — the full bundle, including each snapshot's consent provenance). Erasure is strictly key-gated (a non-GET never reaches the route via an admin session); export is readable by key or admin session like the other reads. - **Consent provenance + IP minimization on the server** ([ADR-0004](docs/adr/0004-consent-and-data-lifecycle.md), migration 012): snapshots now record the lawful basis, consent version, and grant time the SDK forwards (falling back to a per-project `lawful_basis_default`). The stored `client_ip` is **network-truncated by default** (`/24` IPv4, `/48` IPv6 via `minimizeIp`) — still city-accurate for impossible-travel, while the full IP is used only transiently in the request path (so the anonymizer detector is unaffected). A per-project `store_full_ip` flag keeps the full address for operators with a documented basis. New `projects.retention_days` column lands here for the upcoming retention sweeper. - **Demo recognises a new device on re-observe**: the demo showed the synchronous `POST /v1/resolve` result, then committed via `sdk.flush()` (async ingest). `flush()` returns on enqueue, not on commit, so a first-sight device read as "new" on every click. The demo now polls resolve (bounded) until the commit lands before re-enabling Observe, and the status spells out the eventual-consistency step — so the next click resolves against committed history and recognises the device. - **Admin two-factor auth (TOTP)** (migration 011): authenticator-app codes (otplib) with hashed one-time recovery codes; the shared secret is AES-256-GCM-encrypted at rest via a new `SCENT_SECRET_KEY` app key (unset = 2FA enrollment disabled, server still runs). Login gains a second-factor step (code or recovery code). Owners can require 2FA install-wide; un-enrolled admins are funneled into setup (login still issues a session, so no lockout). Final PR of the 3-PR admin arc. New env var `SCENT_SECRET_KEY` (`openssl rand -hex 32`) — documented in the deploy runbook + image config. diff --git a/packages/server/src/routes/events.integration.test.ts b/packages/server/src/routes/events.integration.test.ts index f39b9e2..3ff4ddd 100644 --- a/packages/server/src/routes/events.integration.test.ts +++ b/packages/server/src/routes/events.integration.test.ts @@ -328,3 +328,48 @@ describe.skipIf(!hasDb)('account linking + coordinated_accounts (integration)', expect(r.risk.flags).toContain('coordinated_accounts'); }); }); + +describe.skipIf(!hasDb)('data-subject endpoints — export + erasure (integration)', () => { + // A distinct device so erasing it can't disturb the other groups' identities. + const SIGNALS_D = { + ...SIGNALS, + 'canvas.2d': 'canvashash-integration-DDD', + 'webgl.renderer': 'Delta Arc 770', + 'audio.fp': 'audiofp-404', + 'fonts.list': 'Menlo,Monaco,Inconsolata', + } as const; + + it('GET /export returns the full bundle, then DELETE erases it (cascade)', async () => { + const r = await resolve(crypto.randomUUID(), ts(40_000), SIGNALS_D); + const id = r.identityId; + + const exp = await request(app).get(`/v1/identity/${id}/export`).set('X-Api-Key', API_KEY); + expect(exp.status).toBe(200); + expect(exp.body.identity.id).toBe(id); + expect(exp.body.snapshots.length).toBeGreaterThanOrEqual(1); + expect(Array.isArray(exp.body.accounts)).toBe(true); + + const del = await request(app).delete(`/v1/identity/${id}`).set('X-Api-Key', API_KEY); + expect(del.status).toBe(204); + + const after = await request(app).get(`/v1/identity/${id}`).set('X-Api-Key', API_KEY); + expect(after.status).toBe(404); + + const snaps = await db<{ count: string }[]>` + SELECT COUNT(*)::text AS count FROM snapshots WHERE identity_id = ${id} + `; + expect(snaps[0]!.count).toBe('0'); // snapshots cascaded + }); + + it('DELETE without an API key is rejected (401 — erasure is key-gated)', async () => { + const res = await request(app).delete(`/v1/identity/${crypto.randomUUID()}`); + expect(res.status).toBe(401); + }); + + it('DELETE of an unknown identity returns 404', async () => { + const res = await request(app) + .delete(`/v1/identity/${crypto.randomUUID()}`) + .set('X-Api-Key', API_KEY); + expect(res.status).toBe(404); + }); +}); diff --git a/packages/server/src/routes/identity.ts b/packages/server/src/routes/identity.ts index c561ec9..0b7d688 100644 --- a/packages/server/src/routes/identity.ts +++ b/packages/server/src/routes/identity.ts @@ -150,6 +150,67 @@ identityRouter.get('/:id/accounts', async (req: Request, res: Response): Promise res.json({ identityId: req.params['id'], accounts: links }); }); +// GDPR Art. 17 (right to erasure): delete the identity and everything held about it. +// Snapshots, drifts, risk assessments, cluster merges, and account links all cascade +// (ON DELETE CASCADE on identity_id). Project-scoped. Strictly key-gated: a non-GET +// never reaches here via an admin session (requireProjectRead returns 401), so an +// operator must use a project key to erase. +identityRouter.delete('/:id', async (req: Request, res: Response): Promise => { + const projectId = req.projectId; + const deleted = await db<{ id: string }[]>` + DELETE FROM identities + WHERE id = ${req.params['id']!} AND project_id = ${projectId} + RETURNING id + `; + if (!deleted[0]) { + res.status(404).json({ error: 'Identity not found' }); + return; + } + res.status(204).end(); +}); + +// GDPR Art. 20 (data portability): everything held about an identity, as one JSON +// bundle — the identity record plus its snapshots (with consent provenance), drifts, +// risk assessments, and linked accounts. +identityRouter.get('/:id/export', async (req: Request, res: Response): Promise => { + const projectId = req.projectId; + const identityId = req.params['id']!; + + const [identity] = await db` + SELECT id, first_seen, last_seen, confidence_band, risk_band, snapshot_count, cluster_id + FROM identities WHERE id = ${identityId} AND project_id = ${projectId} LIMIT 1 + `; + if (!identity) { + res.status(404).json({ error: 'Identity not found' }); + return; + } + + const [snapshots, drifts, riskAssessments, accounts] = await Promise.all([ + db` + SELECT id, timestamp, signals, signal_hash, persistence_policy, traceparent, + host(client_ip) AS client_ip, lawful_basis, consent_version, consented_at + FROM snapshots WHERE identity_id = ${identityId} ORDER BY timestamp ASC + `, + db` + SELECT id, timestamp, classification, entropy, + changed_signals, added_signals, removed_signals + FROM drifts WHERE identity_id = ${identityId} ORDER BY timestamp ASC + `, + db` + SELECT id, timestamp, score, band, flags + FROM risk_assessments WHERE identity_id = ${identityId} ORDER BY timestamp ASC + `, + db` + SELECT account_id, first_linked_at, last_linked_at, link_count + FROM identity_account_links + WHERE project_id = ${projectId} AND identity_id = ${identityId} + ORDER BY first_linked_at ASC + `, + ]); + + res.json({ identity, snapshots, drifts, riskAssessments, accounts }); +}); + // Current signal profile with per-signal explainability metadata. identityRouter.get('/:id/signals', async (req: Request, res: Response): Promise => { const projectId = req.projectId;