Skip to content

Commit a2b0d46

Browse files
jim-counterclaude
andcommitted
feat: account deletion and data anonymisation (#626)
Implement GDPR/CCPA-compliant account deletion with 30-day grace period, PII anonymisation across auth and backend services, admin visibility, and audit logging. - User can request deletion from profile, cancel via global banner - 30-day grace period before automated anonymisation - Backend worker job processes due requests: soft-deletes ownership, expires credits/intents, anonymises username/avatar, removes org memberships, revokes API keys - Admin panel for viewing requests, filtering by status, adding notes - Audit log tracks all anonymisation actions for compliance - All timestamp columns use timestamptz for correct timezone handling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ca24477 commit a2b0d46

36 files changed

Lines changed: 2401 additions & 9 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { DeletionUseCases } from '../src/useCases/deletion.js'
2+
import { UsersUseCases } from '../src/useCases/users.js'
3+
import { DeletionRequestStatus, User } from '@auto-drive/models'
4+
import { closeDatabase, getDatabase } from '../src/drivers/pg.js'
5+
import { dbMigration } from './utils/dbMigrate.js'
6+
import { createUnonboardedUser } from './utils/mocks.js'
7+
8+
describe('DeletionUseCases', () => {
9+
let testUser: User
10+
11+
beforeAll(async () => {
12+
await getDatabase()
13+
await dbMigration.up()
14+
15+
// Onboard a test user
16+
const unonboarded = createUnonboardedUser()
17+
const onboarded = await UsersUseCases.onboardUser(unonboarded)
18+
if (!onboarded) {
19+
throw new Error('Failed to onboard test user')
20+
}
21+
testUser = onboarded
22+
})
23+
24+
afterAll(async () => {
25+
await closeDatabase()
26+
await dbMigration.down()
27+
})
28+
29+
it('should create a deletion request', async () => {
30+
const request = await DeletionUseCases.requestDeletion(
31+
testUser,
32+
'Testing deletion',
33+
)
34+
35+
expect(request).toBeDefined()
36+
expect(request.userPublicId).toBe(testUser.publicId)
37+
expect(request.status).toBe(DeletionRequestStatus.Pending)
38+
expect(request.reason).toBe('Testing deletion')
39+
expect(request.scheduledAnonymisationAt).toBeDefined()
40+
41+
// Scheduled date should be approximately 30 days from now
42+
const scheduledDate = new Date(request.scheduledAnonymisationAt)
43+
const now = new Date()
44+
const diffDays =
45+
(scheduledDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
46+
expect(diffDays).toBeGreaterThan(29)
47+
expect(diffDays).toBeLessThan(31)
48+
})
49+
50+
it('should return existing request if one is already pending', async () => {
51+
const request1 = await DeletionUseCases.requestDeletion(testUser)
52+
const request2 = await DeletionUseCases.requestDeletion(testUser)
53+
54+
expect(request1.id).toBe(request2.id)
55+
})
56+
57+
it('should return deletion status for user', async () => {
58+
const status = await DeletionUseCases.getDeletionStatus(testUser)
59+
60+
expect(status).toBeDefined()
61+
expect(status?.status).toBe(DeletionRequestStatus.Pending)
62+
})
63+
64+
it('should cancel a deletion request', async () => {
65+
const cancelled = await DeletionUseCases.cancelDeletion(testUser)
66+
67+
expect(cancelled).toBeDefined()
68+
expect(cancelled?.status).toBe(DeletionRequestStatus.Cancelled)
69+
})
70+
71+
it('should return null when no pending request exists', async () => {
72+
const status = await DeletionUseCases.getDeletionStatus(testUser)
73+
expect(status).toBeNull()
74+
})
75+
76+
it('should return null when cancelling with no pending request', async () => {
77+
const result = await DeletionUseCases.cancelDeletion(testUser)
78+
expect(result).toBeNull()
79+
})
80+
81+
describe('admin operations', () => {
82+
it('should throw for non-admin user listing deletion requests', async () => {
83+
await expect(
84+
DeletionUseCases.getAllDeletionRequests(testUser),
85+
).rejects.toThrow('User does not have admin privileges')
86+
})
87+
88+
it('should throw for non-admin user updating admin notes', async () => {
89+
await expect(
90+
DeletionUseCases.updateAdminNotes(testUser, 'some-id', 'notes'),
91+
).rejects.toThrow('User does not have admin privileges')
92+
})
93+
})
94+
})
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
'use strict'
2+
3+
var dbm
4+
var type
5+
var seed
6+
var fs = require('fs')
7+
var path = require('path')
8+
var Promise
9+
10+
/**
11+
* We receive the dbmigrate dependency from dbmigrate initially.
12+
* This enables us to not have to rely on NODE_PATH.
13+
*/
14+
exports.setup = function (options, seedLink) {
15+
dbm = options.dbmigrate
16+
type = dbm.dataType
17+
seed = seedLink
18+
Promise = options.Promise
19+
}
20+
21+
exports.up = function (db) {
22+
var filePath = path.join(
23+
__dirname,
24+
'sqls',
25+
'20260331000000-deletion-requests-up.sql',
26+
)
27+
return new Promise(function (resolve, reject) {
28+
fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) {
29+
if (err) return reject(err)
30+
31+
resolve(data)
32+
})
33+
}).then(function (data) {
34+
return db.runSql(data)
35+
})
36+
}
37+
38+
exports.down = function (db) {
39+
var filePath = path.join(
40+
__dirname,
41+
'sqls',
42+
'20260331000000-deletion-requests-down.sql',
43+
)
44+
return new Promise(function (resolve, reject) {
45+
fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) {
46+
if (err) return reject(err)
47+
48+
resolve(data)
49+
})
50+
}).then(function (data) {
51+
return db.runSql(data)
52+
})
53+
}
54+
55+
exports._meta = {
56+
version: 1,
57+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE IF EXISTS users.deletion_requests;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
CREATE TABLE users.deletion_requests (
2+
id text NOT NULL DEFAULT gen_random_uuid()::text,
3+
user_public_id text NOT NULL,
4+
oauth_provider text NOT NULL,
5+
oauth_user_id text NOT NULL,
6+
status text NOT NULL DEFAULT 'pending',
7+
requested_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
8+
scheduled_anonymisation_at timestamptz NOT NULL,
9+
completed_at timestamptz NULL,
10+
cancelled_at timestamptz NULL,
11+
reason text NULL,
12+
admin_notes text NULL,
13+
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
14+
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
15+
CONSTRAINT deletion_requests_pkey PRIMARY KEY (id),
16+
CONSTRAINT deletion_requests_status_check CHECK (status IN ('pending', 'cancelled', 'processing', 'completed', 'failed')),
17+
CONSTRAINT deletion_requests_user_fk FOREIGN KEY (oauth_provider, oauth_user_id)
18+
REFERENCES users.users(oauth_provider, oauth_user_id)
19+
);
20+
21+
CREATE INDEX idx_deletion_requests_status ON users.deletion_requests (status);
22+
CREATE INDEX idx_deletion_requests_scheduled ON users.deletion_requests (scheduled_anonymisation_at) WHERE status = 'pending';
23+
CREATE INDEX idx_deletion_requests_user ON users.deletion_requests (user_public_id);

0 commit comments

Comments
 (0)