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
160 changes: 160 additions & 0 deletions backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,130 @@ paths:
schema:
$ref: '#/components/schemas/Error'

/api/v1/pii/export-user:
post:
summary: Export all off-chain PII for a wallet address or email (GDPR right of access)
description: >-
Returns every row across off-chain tables (participation, credits/claims,
referrals, notification preferences, push subscriptions, etc.) matching the
given identifier. Web Push credential fields (`p256dh`, `auth`) are redacted.
See `docs/EVENT_SCHEMA.md` for the on-chain immutability notes this endpoint
is scoped by.
tags:
- Admin
operationId: exportPiiForUser
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- identifier
properties:
identifier:
type: string
description: Wallet address (G...) or email to export data for.
responses:
'200':
description: Export payload
content:
application/json:
schema:
$ref: '#/components/schemas/PiiExportResponse'
'400':
description: Missing identifier
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'

/api/v1/pii/purge-user:
post:
summary: Erase all off-chain PII for a wallet address or email (GDPR right to erasure)
description: >-
Hard-deletes every row across off-chain tables matching the given identifier.
Has no effect on-chain — see `docs/EVENT_SCHEMA.md` for the immutability and
re-indexing caveats. Idempotent: re-running against an already-purged
identifier is a safe no-op.
tags:
- Admin
operationId: purgePiiForUser
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- identifier
properties:
identifier:
type: string
description: Wallet address (G...) or email to purge data for.
responses:
'200':
description: Purge result
content:
application/json:
schema:
$ref: '#/components/schemas/PiiPurgeResponse'
'400':
description: Missing identifier
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'

/api/v1/pii/purge-campaign:
post:
summary: Erase campaign-scoped PII rows (referrals, allowlist entries, etc.)
tags:
- Admin
operationId: purgePiiForCampaign
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- campaignId
properties:
campaignId:
type: string
responses:
'200':
description: Purge result
content:
application/json:
schema:
$ref: '#/components/schemas/PiiPurgeResponse'
'400':
description: Missing campaignId
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'

/api/v1/audit-logs:
get:
summary: List audit logs
Expand Down Expand Up @@ -2005,6 +2129,42 @@ components:
Per-key rate-limit tier enforced by the rate limiter (#924). `standard`
is 60 req/min, `pro` is 300 req/min, `enterprise` is 1000 req/min.

PiiExportResponse:
type: object
properties:
success:
type: boolean
identifier:
type: string
exportedAt:
type: string
format: date-time
data:
type: object
description: >-
One key per off-chain table with at least one matching row (empty
tables are omitted). Values are arrays of the raw rows, except
push_subscriptions.p256dh/auth which are redacted.
additionalProperties:
type: array
items:
type: object

PiiPurgeResponse:
type: object
properties:
success:
type: boolean
purged:
type: array
items:
type: object
properties:
table:
type: string
count:
type: integer

Error:
type: object
required:
Expand Down
89 changes: 88 additions & 1 deletion backend/src/dal/softDelete.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import test from 'node:test';
import Database from 'better-sqlite3';
import { runMigrations } from '../db/migrate.js';
import { createSqliteCampaignRepository } from './sqliteCampaignRepository.js';
import { purgePiiForUser, purgePiiForCampaign } from '../services/piiPurgeService.js';
import { purgePiiForUser, purgePiiForCampaign, exportPiiForUser } from '../services/piiPurgeService.js';

async function setupTestRepository(seed = []) {
const db = new Database(':memory:');
Expand Down Expand Up @@ -288,6 +288,93 @@ test('purgePiiForUser returns empty result when no PII found', async () => {
assert.equal(result.purged.length, 0);
});

test('purgePiiForUser covers notification_preferences and unsubscribe_tokens (#927)', async () => {
const { db } = await setupTestRepository();
const now = new Date().toISOString();

db.prepare(
'INSERT INTO notification_preferences (user_address, channel, event_type, enabled, updated_at) VALUES (?, ?, ?, 1, ?)',
).run('user1', 'email', '*', now);
db.prepare(
'INSERT INTO unsubscribe_tokens (token, user_address, channel, created_at) VALUES (?, ?, ?, ?)',
).run('tok-1', 'user1', 'email', now);

const result = purgePiiForUser(db, 'user1');
assert.ok(result.purged.some((p) => p.table === 'notification_preferences'));
assert.ok(result.purged.some((p) => p.table === 'unsubscribe_tokens'));
assert.equal(
db.prepare('SELECT COUNT(*) AS n FROM notification_preferences WHERE user_address = ?').get('user1').n,
0,
);
assert.equal(
db.prepare('SELECT COUNT(*) AS n FROM unsubscribe_tokens WHERE user_address = ?').get('user1').n,
0,
);
});

// ─── PII Export Tests (#927) ────────────────────────────────────────────────

test('exportPiiForUser returns matching rows keyed by table', async () => {
const { db } = await setupTestRepository();

db.prepare(
'INSERT INTO referrals (campaign_id, referrer_address, referee_address, created_at) VALUES (?, ?, ?, ?)',
).run(1, 'user1', 'user2', new Date().toISOString());
db.prepare(
'INSERT INTO credit_events (user, amount, ledger, tx_hash, created_at) VALUES (?, ?, ?, ?, ?)',
).run('user1', '100', 42, 'tx1', new Date().toISOString());

const result = exportPiiForUser(db, 'user1');
assert.equal(result.identifier, 'user1');
assert.ok(result.exportedAt);
assert.equal(result.data.referrals.length, 1);
assert.equal(result.data.referrals[0].referrer_address, 'user1');
assert.equal(result.data.credit_events.length, 1);
assert.equal(result.data.credit_events[0].amount, '100');
});

test('exportPiiForUser matches a wallet appearing only as referee, not just referrer', async () => {
const { db } = await setupTestRepository();
db.prepare(
'INSERT INTO referrals (campaign_id, referrer_address, referee_address, created_at) VALUES (?, ?, ?, ?)',
).run(1, 'referrerOnly', 'refereeOnly', new Date().toISOString());

const result = exportPiiForUser(db, 'refereeOnly');
assert.equal(result.data.referrals.length, 1);
assert.equal(result.data.referrals[0].referee_address, 'refereeOnly');
});

test('exportPiiForUser omits tables with no matching rows', async () => {
const { db } = await setupTestRepository();
const result = exportPiiForUser(db, 'nonexistent');
assert.deepEqual(result.data, {});
});

test('exportPiiForUser redacts push_subscriptions credential fields', async () => {
const { db } = await setupTestRepository();
db.prepare(
'INSERT INTO push_subscriptions (user, endpoint, p256dh, auth, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?)',
).run('user1', 'https://push.example/abc', 'real-p256dh-key', 'real-auth-secret', 'test-agent', Date.now());

const result = exportPiiForUser(db, 'user1');
assert.equal(result.data.push_subscriptions.length, 1);
const sub = result.data.push_subscriptions[0];
assert.equal(sub.p256dh, '[REDACTED]');
assert.equal(sub.auth, '[REDACTED]');
assert.equal(sub.endpoint, 'https://push.example/abc', 'endpoint should stay visible');
});

test('exportPiiForUser does not delete or modify any data', async () => {
const { db } = await setupTestRepository();
db.prepare(
'INSERT INTO referrals (campaign_id, referrer_address, referee_address, created_at) VALUES (?, ?, ?, ?)',
).run(1, 'user1', 'user2', new Date().toISOString());

exportPiiForUser(db, 'user1');

assert.equal(db.prepare('SELECT COUNT(*) AS n FROM referrals').get().n, 1);
});

// ─── Integration Test ────────────────────────────────────────────────────────

test('full soft-delete lifecycle: create, soft-delete, restore, hard-delete', async () => {
Expand Down
59 changes: 36 additions & 23 deletions backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ import {
} from './routes/notifications.js';
import { createOperatorBalanceJob } from './jobs/operatorBalanceJob.js';
import { createPruningJob } from './jobs/pruningJob.js';
import { purgePiiForUser, purgePiiForCampaign } from './services/piiPurgeService.js';
import { purgePiiForUser, purgePiiForCampaign, exportPiiForUser } from './services/piiPurgeService.js';
import { createModerationService } from './moderation/moderationService.js';
import { createContentModerationMiddleware } from './middleware/contentModeration.js';
import createFaucetRoutes from './routes/faucet.js';
Expand Down Expand Up @@ -1618,10 +1618,6 @@ export async function createApp(options = {}) {
if (!identifier || typeof identifier !== 'string') {
return res.status(400).json({ error: 'identifier is required', code: 'VALIDATION_ERROR' });
}
const dal = campaignRepository.db ? { db: campaignRepository.db } : null;
if (!dal || !dal.db) {
return res.status(500).json({ error: 'Database not available', code: 'DB_UNAVAILABLE' });
}
const result = purgePiiForUser(dal.db, identifier);
recordAuditEntry(req, {
action: 'pii_purge',
Expand All @@ -1638,10 +1634,6 @@ export async function createApp(options = {}) {
if (!campaignId) {
return res.status(400).json({ error: 'campaignId is required', code: 'VALIDATION_ERROR' });
}
const dal = campaignRepository.db ? { db: campaignRepository.db } : null;
if (!dal || !dal.db) {
return res.status(500).json({ error: 'Database not available', code: 'DB_UNAVAILABLE' });
}
const result = purgePiiForCampaign(dal.db, campaignId);
recordAuditEntry(req, {
action: 'pii_purge',
Expand All @@ -1652,6 +1644,24 @@ export async function createApp(options = {}) {
return res.json({ success: true, ...result });
}

/** @param {import('express').Request} req @param {import('express').Response} res */
function exportPiiUser(req, res) {
const { identifier } = req.body;
if (!identifier || typeof identifier !== 'string') {
return res.status(400).json({ error: 'identifier is required', code: 'VALIDATION_ERROR' });
}
const result = exportPiiForUser(dal.db, identifier);
recordAuditEntry(req, {
action: 'pii_export',
entity: 'user',
entityId: identifier,
// Row counts only — never the exported data itself — so the audit
// trail never becomes a second copy of the PII it's logging about.
diff: { tables: Object.fromEntries(Object.entries(result.data).map(([t, rows]) => [t, rows.length])) },
});
return res.json({ success: true, ...result });
}

/** @param {import('express').Request} req @param {import('express').Response} res */
function cloneCampaign(req, res) {
const sourceId = req.params.id;
Expand Down Expand Up @@ -2297,32 +2307,35 @@ export async function createApp(options = {}) {
listDeletedCampaigns,
);

// PII purge routes (admin only)
app.post(
`${prefix}/pii/purge-user`,
rateLimiter,
requireApiKey,
purgePiiUser,
);
// GDPR / PII purge + export routes (admin only, issue #927).
//
// Bug fix: this used to be two competing route registrations per path —
// Express only ever dispatches the first match, so the second
// (requireScope('org:manage'), a scope that isn't even in
// VALID_API_KEY_SCOPES and so could never actually pass) was silently
// unreachable dead code. The live behavior was "any valid tenant API
// key can purge any user's PII site-wide" — replaced with a single
// requireMasterKey-gated registration per path, consistent with every
// other admin-sensitive route in this file.
app.post(
`${prefix}/pii/purge-user`,
rateLimiter,
...guard,
requireScope('org:manage'),
idempotencyMiddleware,
requireMasterKey,
purgePiiUser,
);
app.post(
`${prefix}/pii/purge-campaign`,
rateLimiter,
requireApiKey,
idempotencyMiddleware,
requireMasterKey,
purgePiiCampaign,
);
app.post(
`${prefix}/pii/purge-campaign`,
`${prefix}/pii/export-user`,
rateLimiter,
...guard,
requireScope('org:manage'),
purgePiiCampaign,
requireMasterKey,
exportPiiUser,
);

// Campaign translations (i18n)
Expand Down
Loading