Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions packages/server/src/routes/events.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
61 changes: 61 additions & 0 deletions packages/server/src/routes/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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<void> => {
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<void> => {
const projectId = req.projectId;
Expand Down
Loading