diff --git a/apps/auth/__tests__/deletion.spec.ts b/apps/auth/__tests__/deletion.spec.ts new file mode 100644 index 000000000..6449e4007 --- /dev/null +++ b/apps/auth/__tests__/deletion.spec.ts @@ -0,0 +1,94 @@ +import { DeletionUseCases } from '../src/useCases/deletion.js' +import { UsersUseCases } from '../src/useCases/users.js' +import { DeletionRequestStatus, User } from '@auto-drive/models' +import { closeDatabase, getDatabase } from '../src/drivers/pg.js' +import { dbMigration } from './utils/dbMigrate.js' +import { createUnonboardedUser } from './utils/mocks.js' + +describe('DeletionUseCases', () => { + let testUser: User + + beforeAll(async () => { + await getDatabase() + await dbMigration.up() + + // Onboard a test user + const unonboarded = createUnonboardedUser() + const onboarded = await UsersUseCases.onboardUser(unonboarded) + if (!onboarded) { + throw new Error('Failed to onboard test user') + } + testUser = onboarded + }) + + afterAll(async () => { + await closeDatabase() + await dbMigration.down() + }) + + it('should create a deletion request', async () => { + const request = await DeletionUseCases.requestDeletion( + testUser, + 'Testing deletion', + ) + + expect(request).toBeDefined() + expect(request.userPublicId).toBe(testUser.publicId) + expect(request.status).toBe(DeletionRequestStatus.Pending) + expect(request.reason).toBe('Testing deletion') + expect(request.scheduledAnonymisationAt).toBeDefined() + + // Scheduled date should be approximately 30 days from now + const scheduledDate = new Date(request.scheduledAnonymisationAt) + const now = new Date() + const diffDays = + (scheduledDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) + expect(diffDays).toBeGreaterThan(29) + expect(diffDays).toBeLessThan(31) + }) + + it('should return existing request if one is already pending', async () => { + const request1 = await DeletionUseCases.requestDeletion(testUser) + const request2 = await DeletionUseCases.requestDeletion(testUser) + + expect(request1.id).toBe(request2.id) + }) + + it('should return deletion status for user', async () => { + const status = await DeletionUseCases.getDeletionStatus(testUser) + + expect(status).toBeDefined() + expect(status?.status).toBe(DeletionRequestStatus.Pending) + }) + + it('should cancel a deletion request', async () => { + const cancelled = await DeletionUseCases.cancelDeletion(testUser) + + expect(cancelled).toBeDefined() + expect(cancelled?.status).toBe(DeletionRequestStatus.Cancelled) + }) + + it('should return null when no pending request exists', async () => { + const status = await DeletionUseCases.getDeletionStatus(testUser) + expect(status).toBeNull() + }) + + it('should return null when cancelling with no pending request', async () => { + const result = await DeletionUseCases.cancelDeletion(testUser) + expect(result).toBeNull() + }) + + describe('admin operations', () => { + it('should throw for non-admin user listing deletion requests', async () => { + await expect( + DeletionUseCases.getAllDeletionRequests(testUser), + ).rejects.toThrow('User does not have admin privileges') + }) + + it('should throw for non-admin user updating admin notes', async () => { + await expect( + DeletionUseCases.updateAdminNotes(testUser, 'some-id', 'notes'), + ).rejects.toThrow('User does not have admin privileges') + }) + }) +}) diff --git a/apps/auth/migrations/20260331000000-deletion-requests.js b/apps/auth/migrations/20260331000000-deletion-requests.js new file mode 100644 index 000000000..3864919ec --- /dev/null +++ b/apps/auth/migrations/20260331000000-deletion-requests.js @@ -0,0 +1,57 @@ +'use strict' + +var dbm +var type +var seed +var fs = require('fs') +var path = require('path') +var Promise + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate + type = dbm.dataType + seed = seedLink + Promise = options.Promise +} + +exports.up = function (db) { + var filePath = path.join( + __dirname, + 'sqls', + '20260331000000-deletion-requests-up.sql', + ) + return new Promise(function (resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) { + if (err) return reject(err) + + resolve(data) + }) + }).then(function (data) { + return db.runSql(data) + }) +} + +exports.down = function (db) { + var filePath = path.join( + __dirname, + 'sqls', + '20260331000000-deletion-requests-down.sql', + ) + return new Promise(function (resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) { + if (err) return reject(err) + + resolve(data) + }) + }).then(function (data) { + return db.runSql(data) + }) +} + +exports._meta = { + version: 1, +} diff --git a/apps/auth/migrations/sqls/20260331000000-deletion-requests-down.sql b/apps/auth/migrations/sqls/20260331000000-deletion-requests-down.sql new file mode 100644 index 000000000..11fa5f714 --- /dev/null +++ b/apps/auth/migrations/sqls/20260331000000-deletion-requests-down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users.deletion_requests; diff --git a/apps/auth/migrations/sqls/20260331000000-deletion-requests-up.sql b/apps/auth/migrations/sqls/20260331000000-deletion-requests-up.sql new file mode 100644 index 000000000..32bced062 --- /dev/null +++ b/apps/auth/migrations/sqls/20260331000000-deletion-requests-up.sql @@ -0,0 +1,23 @@ +CREATE TABLE users.deletion_requests ( + id text NOT NULL DEFAULT gen_random_uuid()::text, + user_public_id text NOT NULL, + oauth_provider text NOT NULL, + oauth_user_id text NOT NULL, + status text NOT NULL DEFAULT 'pending', + requested_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + scheduled_anonymisation_at timestamptz NOT NULL, + completed_at timestamptz NULL, + cancelled_at timestamptz NULL, + reason text NULL, + admin_notes text NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT deletion_requests_pkey PRIMARY KEY (id), + CONSTRAINT deletion_requests_status_check CHECK (status IN ('pending', 'cancelled', 'processing', 'completed', 'failed')), + CONSTRAINT deletion_requests_user_fk FOREIGN KEY (oauth_provider, oauth_user_id) + REFERENCES users.users(oauth_provider, oauth_user_id) +); + +CREATE INDEX idx_deletion_requests_status ON users.deletion_requests (status); +CREATE INDEX idx_deletion_requests_scheduled ON users.deletion_requests (scheduled_anonymisation_at) WHERE status = 'pending'; +CREATE INDEX idx_deletion_requests_user ON users.deletion_requests (user_public_id); diff --git a/apps/auth/src/controllers/user.ts b/apps/auth/src/controllers/user.ts index 45ee672df..8298a2b62 100644 --- a/apps/auth/src/controllers/user.ts +++ b/apps/auth/src/controllers/user.ts @@ -6,8 +6,9 @@ import { refreshAccessToken, } from '../services/authManager/express.js' import { UsersUseCases } from '../useCases/index.js' +import { DeletionUseCases } from '../useCases/deletion.js' import { ApiKeysUseCases } from '../useCases/apikeys.js' -import { UserRole } from '@auto-drive/models' +import { DeletionRequestStatus, UserRole } from '@auto-drive/models' import { CustomJWTAuth } from '../services/authManager/providers/custom.js' import { createLogger } from '../drivers/logger.js' @@ -292,6 +293,218 @@ userController.post('/batch', async (req: Request, res: Response) => { } }) +// --- Account Deletion Endpoints --- + +userController.post('/@me/deletion', async (req: Request, res: Response) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + try { + const { reason } = req.body ?? {} + const request = await DeletionUseCases.requestDeletion( + user, + typeof reason === 'string' ? reason : undefined, + ) + res.status(201).json(request) + } catch (error) { + logger.error(error) + res.status(500).json({ error: 'Failed to request account deletion' }) + } +}) + +userController.delete('/@me/deletion', async (req: Request, res: Response) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + try { + const result = await DeletionUseCases.cancelDeletion(user) + if (!result) { + res.status(404).json({ error: 'No pending deletion request found' }) + return + } + res.json(result) + } catch (error) { + logger.error(error) + res.status(500).json({ error: 'Failed to cancel deletion' }) + } +}) + +userController.get('/@me/deletion', async (req: Request, res: Response) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + try { + const request = await DeletionUseCases.getDeletionStatus(user) + res.json(request) + } catch (error) { + logger.error(error) + res.status(500).json({ error: 'Failed to get deletion status' }) + } +}) + +// --- Admin Deletion Endpoints --- + +userController.get( + '/admin/deletions', + async (req: Request, res: Response) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + try { + const status = req.query.status as DeletionRequestStatus | undefined + const requests = await DeletionUseCases.getAllDeletionRequests(user, status) + res.json(requests) + } catch (error) { + logger.error(error) + res.status(500).json({ error: 'Failed to get deletion requests' }) + } + }, +) + +userController.post( + '/admin/deletions/:id/notes', + async (req: Request, res: Response) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const { notes } = req.body + if (typeof notes !== 'string') { + res.status(400).json({ error: 'Missing or invalid attribute `notes` in body' }) + return + } + + try { + const request = await DeletionUseCases.updateAdminNotes( + user, + req.params.id, + notes, + ) + res.json(request) + } catch (error) { + logger.error(error) + res.status(500).json({ error: 'Failed to update admin notes' }) + } + }, +) + +// --- Internal Deletion Endpoints (called by backend service) --- + +userController.get( + '/admin/deletions/due', + async (req: Request, res: Response) => { + const isAdmin = await handleAdminAuth(req, res) + if (!isAdmin) { + return + } + + try { + const requests = await DeletionUseCases.getDueForAnonymisation() + res.json(requests) + } catch (error) { + logger.error(error) + res.status(500).json({ error: 'Failed to get due deletion requests' }) + } + }, +) + +userController.post( + '/admin/deletions/:id/process', + async (req: Request, res: Response) => { + const isAdmin = await handleAdminAuth(req, res) + if (!isAdmin) { + return + } + + try { + const result = await DeletionUseCases.markAsProcessing(req.params.id) + if (!result) { + res.status(404).json({ error: 'Deletion request not found or not pending' }) + return + } + res.json(result) + } catch (error) { + logger.error(error) + res.status(500).json({ error: 'Failed to mark as processing' }) + } + }, +) + +userController.post( + '/admin/deletions/:id/anonymise', + async (req: Request, res: Response) => { + const isAdmin = await handleAdminAuth(req, res) + if (!isAdmin) { + return + } + + try { + await DeletionUseCases.anonymiseUser(req.params.id) + res.sendStatus(200) + } catch (error) { + logger.error(error) + res.status(500).json({ error: 'Failed to anonymise user' }) + } + }, +) + +userController.post( + '/admin/deletions/:id/complete', + async (req: Request, res: Response) => { + const isAdmin = await handleAdminAuth(req, res) + if (!isAdmin) { + return + } + + try { + const result = await DeletionUseCases.markAsCompleted(req.params.id) + if (!result) { + res.status(404).json({ error: 'Deletion request not found' }) + return + } + res.json(result) + } catch (error) { + logger.error(error) + res.status(500).json({ error: 'Failed to mark as completed' }) + } + }, +) + +userController.post( + '/admin/deletions/:id/fail', + async (req: Request, res: Response) => { + const isAdmin = await handleAdminAuth(req, res) + if (!isAdmin) { + return + } + + try { + const { adminNotes } = req.body ?? {} + const result = await DeletionUseCases.markAsFailed( + req.params.id, + typeof adminNotes === 'string' ? adminNotes : undefined, + ) + if (!result) { + res.status(404).json({ error: 'Deletion request not found' }) + return + } + res.json(result) + } catch (error) { + logger.error(error) + res.status(500).json({ error: 'Failed to mark as failed' }) + } + }, +) + userController.get('/:publicId', async (req: Request, res: Response) => { const { publicId } = req.params diff --git a/apps/auth/src/repositories/deletionRequests.ts b/apps/auth/src/repositories/deletionRequests.ts new file mode 100644 index 000000000..c326a972e --- /dev/null +++ b/apps/auth/src/repositories/deletionRequests.ts @@ -0,0 +1,193 @@ +import { getDatabase } from '../drivers/pg.js' +import { DeletionRequestStatus } from '@auto-drive/models' + +export interface DeletionRequestRow { + id: string + user_public_id: string + oauth_provider: string + oauth_user_id: string + status: DeletionRequestStatus + requested_at: Date + scheduled_anonymisation_at: Date + completed_at: Date | null + cancelled_at: Date | null + reason: string | null + admin_notes: string | null + created_at: Date + updated_at: Date +} + +export interface DeletionRequestWithUserRow extends DeletionRequestRow { + oauth_username: string | null +} + +const createDeletionRequest = async ( + userPublicId: string, + oauthProvider: string, + oauthUserId: string, + scheduledAnonymisationAt: Date, + reason?: string, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `INSERT INTO users.deletion_requests + (user_public_id, oauth_provider, oauth_user_id, scheduled_anonymisation_at, reason) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [userPublicId, oauthProvider, oauthUserId, scheduledAnonymisationAt, reason ?? null], + ) + + return result.rows[0] +} + +const getPendingByUser = async ( + oauthProvider: string, + oauthUserId: string, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `SELECT * FROM users.deletion_requests + WHERE oauth_provider = $1 AND oauth_user_id = $2 AND status = $3 + ORDER BY requested_at DESC LIMIT 1`, + [oauthProvider, oauthUserId, DeletionRequestStatus.Pending], + ) + + return result.rows.at(0) ?? null +} + +const getPendingByPublicId = async ( + userPublicId: string, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `SELECT * FROM users.deletion_requests + WHERE user_public_id = $1 AND status = $2 + ORDER BY requested_at DESC LIMIT 1`, + [userPublicId, DeletionRequestStatus.Pending], + ) + + return result.rows.at(0) ?? null +} + +const cancelRequest = async (id: string): Promise => { + const db = await getDatabase() + const result = await db.query( + `UPDATE users.deletion_requests + SET status = $1, cancelled_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND status = $3 + RETURNING *`, + [DeletionRequestStatus.Cancelled, id, DeletionRequestStatus.Pending], + ) + + return result.rows.at(0) ?? null +} + +const getById = async (id: string): Promise => { + const db = await getDatabase() + const result = await db.query( + 'SELECT * FROM users.deletion_requests WHERE id = $1', + [id], + ) + + return result.rows.at(0) ?? null +} + +const getDueForAnonymisation = async (): Promise => { + const db = await getDatabase() + const result = await db.query( + `SELECT * FROM users.deletion_requests + WHERE status = $1 AND scheduled_anonymisation_at <= CURRENT_TIMESTAMP + ORDER BY scheduled_anonymisation_at ASC`, + [DeletionRequestStatus.Pending], + ) + + return result.rows +} + +const updateStatus = async ( + id: string, + status: DeletionRequestStatus, + fromStatus?: DeletionRequestStatus, +): Promise => { + const db = await getDatabase() + const completedAt = + status === DeletionRequestStatus.Completed ? 'CURRENT_TIMESTAMP' : 'completed_at' + + const params: (string | DeletionRequestStatus)[] = [status, id] + let whereClause = 'WHERE id = $2' + + if (fromStatus) { + params.push(fromStatus) + whereClause += ` AND status = $${params.length}` + } + + const result = await db.query( + `UPDATE users.deletion_requests + SET status = $1, completed_at = ${completedAt}, updated_at = CURRENT_TIMESTAMP + ${whereClause} + RETURNING *`, + params, + ) + + return result.rows.at(0) ?? null +} + +const updateAdminNotes = async ( + id: string, + adminNotes: string, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `UPDATE users.deletion_requests + SET admin_notes = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 + RETURNING *`, + [adminNotes, id], + ) + + return result.rows.at(0) ?? null +} + +const getAllByStatus = async ( + status?: DeletionRequestStatus, +): Promise => { + const db = await getDatabase() + + if (status) { + const result = await db.query( + `SELECT dr.*, u.oauth_username + FROM users.deletion_requests dr + JOIN users.users u ON dr.oauth_provider = u.oauth_provider AND dr.oauth_user_id = u.oauth_user_id + WHERE dr.status = $1 + ORDER BY dr.requested_at DESC`, + [status], + ) + return result.rows + } + + const result = await db.query( + `SELECT dr.*, u.oauth_username + FROM users.deletion_requests dr + JOIN users.users u ON dr.oauth_provider = u.oauth_provider AND dr.oauth_user_id = u.oauth_user_id + ORDER BY dr.requested_at DESC`, + ) + + return result.rows +} + +const getAll = async (): Promise => { + return getAllByStatus() +} + +export const deletionRequestsRepository = { + createDeletionRequest, + getPendingByUser, + getPendingByPublicId, + cancelRequest, + getById, + getDueForAnonymisation, + updateStatus, + updateAdminNotes, + getAllByStatus, + getAll, +} diff --git a/apps/auth/src/repositories/index.ts b/apps/auth/src/repositories/index.ts index 17113c7fc..8df43c0e0 100644 --- a/apps/auth/src/repositories/index.ts +++ b/apps/auth/src/repositories/index.ts @@ -3,3 +3,4 @@ export * from './organizations.js' export * from './organizationMembers.js' export * from './users.js' export * from './jwt.js' +export * from './deletionRequests.js' diff --git a/apps/auth/src/repositories/organizationMembers.ts b/apps/auth/src/repositories/organizationMembers.ts index 55293cd65..25c25a1ab 100644 --- a/apps/auth/src/repositories/organizationMembers.ts +++ b/apps/auth/src/repositories/organizationMembers.ts @@ -77,10 +77,23 @@ const getOrganizationMembershipsByUsers = async ( return result.rows } +const removeMemberFromOrganization = async ( + organizationId: string, + oauthProvider: string, + oauthUserId: string, +): Promise => { + const db = await getDatabase() + await db.query( + 'DELETE FROM users.users_organizations WHERE organization_id = $1 AND oauth_provider = $2 AND oauth_user_id = $3', + [organizationId, oauthProvider, oauthUserId], + ) +} + export const organizationMembersRepository = { getOrganizationMemberships, getOrganizationMembershipsByUser, getOrganizationMembershipsByUsers, addMemberToOrganization, + removeMemberFromOrganization, isMemberOfOrganization, } diff --git a/apps/auth/src/useCases/deletion.ts b/apps/auth/src/useCases/deletion.ts new file mode 100644 index 000000000..e8432db98 --- /dev/null +++ b/apps/auth/src/useCases/deletion.ts @@ -0,0 +1,253 @@ +import { createHash } from 'crypto' +import { User, DeletionRequestStatus, DeletionRequest, DeletionRequestWithUser } from '@auto-drive/models' +import { deletionRequestsRepository, DeletionRequestRow, DeletionRequestWithUserRow } from '../repositories/deletionRequests.js' +import { usersRepository } from '../repositories/users.js' +import { apiKeysRepository } from '../repositories/apikeys.js' +import { organizationMembersRepository } from '../repositories/organizationMembers.js' +import { UsersUseCases } from './users.js' +import { createLogger } from '../drivers/logger.js' + +const logger = createLogger('useCases:deletion') + +const GRACE_PERIOD_DAYS = 30 + +const mapRowToDeletionRequest = (row: DeletionRequestRow): DeletionRequest => ({ + id: row.id, + userPublicId: row.user_public_id, + oauthProvider: row.oauth_provider, + oauthUserId: row.oauth_user_id, + status: row.status, + requestedAt: row.requested_at, + scheduledAnonymisationAt: row.scheduled_anonymisation_at, + completedAt: row.completed_at, + cancelledAt: row.cancelled_at, + reason: row.reason, + adminNotes: row.admin_notes, + createdAt: row.created_at, + updatedAt: row.updated_at, +}) + +const mapRowToDeletionRequestWithUser = ( + row: DeletionRequestWithUserRow, +): DeletionRequestWithUser => ({ + ...mapRowToDeletionRequest(row), + oauthUsername: row.oauth_username, +}) + +const hashPublicId = (publicId: string): string => + createHash('sha256').update(publicId).digest('hex').slice(0, 16) + +const requestDeletion = async ( + user: User, + reason?: string, +): Promise => { + logger.info('Deletion requested by user %s', user.publicId) + + const existing = await deletionRequestsRepository.getPendingByUser( + user.oauthProvider, + user.oauthUserId, + ) + if (existing) { + logger.warn('User %s already has a pending deletion request', user.publicId) + return mapRowToDeletionRequest(existing) + } + + const scheduledAt = new Date() + scheduledAt.setDate(scheduledAt.getDate() + GRACE_PERIOD_DAYS) + + const row = await deletionRequestsRepository.createDeletionRequest( + user.publicId, + user.oauthProvider, + user.oauthUserId, + scheduledAt, + reason, + ) + + logger.info( + 'Deletion request %s created for user %s, scheduled for %s', + row.id, + user.publicId, + scheduledAt.toISOString(), + ) + + return mapRowToDeletionRequest(row) +} + +const cancelDeletion = async ( + user: User, +): Promise => { + logger.info('Deletion cancellation requested by user %s', user.publicId) + + const pending = await deletionRequestsRepository.getPendingByUser( + user.oauthProvider, + user.oauthUserId, + ) + if (!pending) { + logger.warn('No pending deletion request found for user %s', user.publicId) + return null + } + + const cancelled = await deletionRequestsRepository.cancelRequest(pending.id) + if (!cancelled) { + return null + } + + logger.info('Deletion request %s cancelled for user %s', pending.id, user.publicId) + return mapRowToDeletionRequest(cancelled) +} + +const getDeletionStatus = async ( + user: User, +): Promise => { + const pending = await deletionRequestsRepository.getPendingByUser( + user.oauthProvider, + user.oauthUserId, + ) + + return pending ? mapRowToDeletionRequest(pending) : null +} + +const getDueForAnonymisation = async (): Promise => { + const rows = await deletionRequestsRepository.getDueForAnonymisation() + return rows.map(mapRowToDeletionRequest) +} + +const anonymiseUser = async (requestId: string): Promise => { + const request = await deletionRequestsRepository.getById(requestId) + if (!request) { + throw new Error(`Deletion request ${requestId} not found`) + } + + if (request.status !== DeletionRequestStatus.Processing) { + throw new Error( + `Deletion request ${requestId} is in status ${request.status}, expected processing`, + ) + } + + const hash = hashPublicId(request.user_public_id) + const anonymisedUsername = `deleted-user-${hash}` + + logger.info('Anonymising auth data for user %s (request %s)', request.user_public_id, requestId) + + // Soft-delete all API keys + const apiKeys = await apiKeysRepository.getApiKeysByOAuthUser( + request.oauth_provider, + request.oauth_user_id, + ) + for (const key of apiKeys) { + if (!key.deletedAt) { + await apiKeysRepository.deleteApiKey(key.id) + } + } + + // Remove from organizations + const memberships = await organizationMembersRepository.getOrganizationMembershipsByUser( + request.oauth_provider, + request.oauth_user_id, + ) + for (const membership of memberships) { + await organizationMembersRepository.removeMemberFromOrganization( + membership.organization_id, + request.oauth_provider, + request.oauth_user_id, + ) + } + + // Anonymise user PII fields + await usersRepository.updateUsername( + request.oauth_provider, + request.oauth_user_id, + anonymisedUsername, + ) + await usersRepository.updateAvatarUrl( + request.oauth_provider, + request.oauth_user_id, + '', + ) + + logger.info('Auth anonymisation complete for request %s', requestId) +} + +const markAsProcessing = async ( + requestId: string, +): Promise => { + const row = await deletionRequestsRepository.updateStatus( + requestId, + DeletionRequestStatus.Processing, + DeletionRequestStatus.Pending, + ) + return row ? mapRowToDeletionRequest(row) : null +} + +const markAsCompleted = async ( + requestId: string, +): Promise => { + const row = await deletionRequestsRepository.updateStatus( + requestId, + DeletionRequestStatus.Completed, + DeletionRequestStatus.Processing, + ) + return row ? mapRowToDeletionRequest(row) : null +} + +const markAsFailed = async ( + requestId: string, + adminNotes?: string, +): Promise => { + if (adminNotes) { + await deletionRequestsRepository.updateAdminNotes(requestId, adminNotes) + } + const row = await deletionRequestsRepository.updateStatus( + requestId, + DeletionRequestStatus.Failed, + DeletionRequestStatus.Processing, + ) + return row ? mapRowToDeletionRequest(row) : null +} + +const getAllDeletionRequests = async ( + executor: User, + status?: DeletionRequestStatus, +): Promise => { + const isAdmin = await UsersUseCases.isAdminUser(executor) + if (!isAdmin) { + throw new Error('User does not have admin privileges') + } + + const rows = status + ? await deletionRequestsRepository.getAllByStatus(status) + : await deletionRequestsRepository.getAll() + + return rows.map(mapRowToDeletionRequestWithUser) +} + +const updateAdminNotes = async ( + executor: User, + requestId: string, + notes: string, +): Promise => { + const isAdmin = await UsersUseCases.isAdminUser(executor) + if (!isAdmin) { + throw new Error('User does not have admin privileges') + } + + const row = await deletionRequestsRepository.updateAdminNotes(requestId, notes) + if (!row) { + throw new Error(`Deletion request ${requestId} not found`) + } + + return mapRowToDeletionRequest(row) +} + +export const DeletionUseCases = { + requestDeletion, + cancelDeletion, + getDeletionStatus, + getDueForAnonymisation, + anonymiseUser, + markAsProcessing, + markAsCompleted, + markAsFailed, + getAllDeletionRequests, + updateAdminNotes, +} diff --git a/apps/auth/src/useCases/index.ts b/apps/auth/src/useCases/index.ts index a2601c075..582aadb96 100644 --- a/apps/auth/src/useCases/index.ts +++ b/apps/auth/src/useCases/index.ts @@ -1,3 +1,4 @@ export * from './users.js' export * from './apikeys.js' export * from './organizations.js' +export * from './deletion.js' diff --git a/apps/backend/__tests__/unit/useCases/deletion.spec.ts b/apps/backend/__tests__/unit/useCases/deletion.spec.ts new file mode 100644 index 000000000..78eeccac5 --- /dev/null +++ b/apps/backend/__tests__/unit/useCases/deletion.spec.ts @@ -0,0 +1,224 @@ +import { + jest, + describe, + it, + expect, + beforeEach, + afterEach, +} from '@jest/globals' +import { DeletionUseCases } from '../../../src/core/users/deletion.js' +import { deletionAuditRepository } from '../../../src/infrastructure/repositories/deletionAudit.js' +import { AuthManager } from '../../../src/infrastructure/services/auth/index.js' +import { + DeletionAuditEntry, + DeletionRequest, + DeletionRequestStatus, + UserRole, + UserWithOrganization, +} from '@auto-drive/models' +import { ForbiddenError } from '../../../src/errors/index.js' +import { v4 as uuidv4 } from 'uuid' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeUser = (role: UserRole): UserWithOrganization => ({ + oauthProvider: 'google', + oauthUserId: uuidv4(), + role, + onboarded: true, + organizationId: uuidv4(), + publicId: uuidv4(), +}) + +const makeAuditEntry = ( + overrides: Partial = {}, +): DeletionAuditEntry => ({ + id: uuidv4(), + userPublicId: uuidv4(), + action: 'anonymisation_completed', + details: null, + performedAt: new Date(), + ...overrides, +}) + +const makeDeletionRequest = ( + overrides: Partial = {}, +): DeletionRequest => ({ + id: uuidv4(), + userPublicId: uuidv4(), + oauthProvider: 'google', + oauthUserId: uuidv4(), + status: DeletionRequestStatus.Pending, + requestedAt: new Date(), + scheduledAnonymisationAt: new Date( + Date.now() + 30 * 24 * 60 * 60 * 1000, + ), + completedAt: null, + cancelledAt: null, + reason: null, + adminNotes: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}) + +// --------------------------------------------------------------------------- +// Admin-only operations — non-admin should receive ForbiddenError +// --------------------------------------------------------------------------- + +describe('DeletionUseCases — admin-only endpoints return 403 for non-admin users', () => { + const regularUser = makeUser(UserRole.User) + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('getAuditLog: returns ForbiddenError for non-admin', async () => { + const result = await DeletionUseCases.getAuditLog( + regularUser, + 'some-public-id', + ) + expect(result.isErr()).toBe(true) + if (result.isErr()) { + expect(result.error).toBeInstanceOf(ForbiddenError) + } + }) + + it('getStats: returns ForbiddenError for non-admin', async () => { + const result = await DeletionUseCases.getStats(regularUser) + expect(result.isErr()).toBe(true) + if (result.isErr()) { + expect(result.error).toBeInstanceOf(ForbiddenError) + } + }) +}) + +// --------------------------------------------------------------------------- +// Admin operations — admin user +// --------------------------------------------------------------------------- + +describe('DeletionUseCases — admin operations', () => { + const adminUser = makeUser(UserRole.Admin) + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('getAuditLog: returns audit entries for admin', async () => { + const entries = [makeAuditEntry(), makeAuditEntry()] + jest + .spyOn(deletionAuditRepository, 'getByUser') + .mockResolvedValue(entries) + + const result = await DeletionUseCases.getAuditLog( + adminUser, + 'some-public-id', + ) + expect(result.isOk()).toBe(true) + if (result.isOk()) { + expect(result.value).toHaveLength(2) + } + }) + + it('getStats: returns stats for admin', async () => { + const stats = { totalAnonymisations: 5, recentAnonymisations: 2 } + jest + .spyOn(deletionAuditRepository, 'getStats') + .mockResolvedValue(stats) + + const result = await DeletionUseCases.getStats(adminUser) + expect(result.isOk()).toBe(true) + if (result.isOk()) { + expect(result.value.totalAnonymisations).toBe(5) + expect(result.value.recentAnonymisations).toBe(2) + } + }) +}) + +// --------------------------------------------------------------------------- +// processAnonymisation +// --------------------------------------------------------------------------- + +describe('DeletionUseCases — processAnonymisation', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('skips request if markAsProcessing returns null (already processed)', async () => { + const request = makeDeletionRequest() + + jest + .spyOn(AuthManager, 'markDeletionAsProcessing') + .mockResolvedValue(null) + + const executeAuthSpy = jest + .spyOn(AuthManager, 'executeAuthAnonymisation') + .mockResolvedValue(undefined) + + await DeletionUseCases.processAnonymisation(request) + + expect(executeAuthSpy).not.toHaveBeenCalled() + }) + + it('marks as failed when backend anonymisation throws', async () => { + const request = makeDeletionRequest() + + jest + .spyOn(AuthManager, 'markDeletionAsProcessing') + .mockResolvedValue( + makeDeletionRequest({ + status: DeletionRequestStatus.Processing, + }), + ) + + const failSpy = jest + .spyOn(AuthManager, 'markDeletionAsFailed') + .mockResolvedValue( + makeDeletionRequest({ status: DeletionRequestStatus.Failed }), + ) + + // anonymiseBackendData will hit the test DB which lacks the + // object_ownership table, causing a real DB error + await DeletionUseCases.processAnonymisation(request) + + expect(failSpy).toHaveBeenCalledWith( + request.id, + expect.stringContaining('Anonymisation failed'), + ) + }) + + it('marks as failed when markDeletionAsProcessing rejects', async () => { + const request = makeDeletionRequest() + + jest + .spyOn(AuthManager, 'markDeletionAsProcessing') + .mockRejectedValue(new Error('Network error')) + + const failSpy = jest + .spyOn(AuthManager, 'markDeletionAsFailed') + .mockResolvedValue( + makeDeletionRequest({ status: DeletionRequestStatus.Failed }), + ) + + await DeletionUseCases.processAnonymisation(request) + + expect(failSpy).toHaveBeenCalledWith( + request.id, + expect.stringContaining('Network error'), + ) + }) +}) diff --git a/apps/backend/migrations/20260331000000-deletion-audit-log.js b/apps/backend/migrations/20260331000000-deletion-audit-log.js new file mode 100644 index 000000000..2106db69e --- /dev/null +++ b/apps/backend/migrations/20260331000000-deletion-audit-log.js @@ -0,0 +1,53 @@ +'use strict' + +var dbm +var type +var seed +var fs = require('fs') +var path = require('path') +var Promise + +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate + type = dbm.dataType + seed = seedLink + Promise = options.Promise +} + +exports.up = function (db) { + var filePath = path.join( + __dirname, + 'sqls', + '20260331000000-deletion-audit-log-up.sql', + ) + return new Promise(function (resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) { + if (err) return reject(err) + console.log('received data: ' + data) + resolve(data) + }) + }).then(function (data) { + return db.runSql(data) + }) +} + +exports.down = function (db) { + var filePath = path.join( + __dirname, + 'sqls', + '20260331000000-deletion-audit-log-down.sql', + ) + return new Promise(function (resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) { + if (err) return reject(err) + console.log('received data: ' + data) + resolve(data) + }) + }).then(function (data) { + return db.runSql(data) + }) +} + +exports._meta = { + version: 1, +} diff --git a/apps/backend/migrations/sqls/20260331000000-deletion-audit-log-down.sql b/apps/backend/migrations/sqls/20260331000000-deletion-audit-log-down.sql new file mode 100644 index 000000000..e9d54985b --- /dev/null +++ b/apps/backend/migrations/sqls/20260331000000-deletion-audit-log-down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.deletion_audit_log; diff --git a/apps/backend/migrations/sqls/20260331000000-deletion-audit-log-up.sql b/apps/backend/migrations/sqls/20260331000000-deletion-audit-log-up.sql new file mode 100644 index 000000000..cf47c84b3 --- /dev/null +++ b/apps/backend/migrations/sqls/20260331000000-deletion-audit-log-up.sql @@ -0,0 +1,11 @@ +CREATE TABLE public.deletion_audit_log ( + id text NOT NULL DEFAULT gen_random_uuid()::text, + user_public_id text NOT NULL, + action text NOT NULL, + details jsonb NULL, + performed_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT deletion_audit_log_pkey PRIMARY KEY (id) +); + +CREATE INDEX idx_deletion_audit_log_user ON public.deletion_audit_log (user_public_id); +CREATE INDEX idx_deletion_audit_log_performed ON public.deletion_audit_log (performed_at); diff --git a/apps/backend/src/app/apis/frontend.ts b/apps/backend/src/app/apis/frontend.ts index 3d9c9d8d3..7c6412e8e 100644 --- a/apps/backend/src/app/apis/frontend.ts +++ b/apps/backend/src/app/apis/frontend.ts @@ -13,6 +13,7 @@ import { intentsController } from '../controllers/intents.js' import { creditsController } from '../controllers/credits.js' import { bannersController } from '../controllers/banners.js' import { touController } from '../controllers/tou.js' +import { deletionController } from '../controllers/deletion.js' import { featuresController } from '../controllers/features.js' import { featureFlagMiddleware } from '../../core/featureFlags/express.js' import { IntentsUseCases } from '../../core/users/intents.js' @@ -78,6 +79,7 @@ const createServer = async () => { app.use('/credits', featureFlagMiddleware('buyCredits'), creditsController) app.use('/banners', bannersController) app.use('/tou', touController) + app.use('/deletion', deletionController) app.use('/features', featuresController) app.use('/docs', docsController) diff --git a/apps/backend/src/app/controllers/deletion.ts b/apps/backend/src/app/controllers/deletion.ts new file mode 100644 index 000000000..996644ba5 --- /dev/null +++ b/apps/backend/src/app/controllers/deletion.ts @@ -0,0 +1,60 @@ +import { Router } from 'express' +import { asyncSafeHandler } from '../../shared/utils/express.js' +import { handleAuth } from '../../infrastructure/services/auth/express.js' +import { DeletionUseCases } from '../../core/users/deletion.js' +import { handleInternalErrorResult } from '../../shared/utils/neverthrow.js' +import { handleError } from '../../errors/index.js' + +export const deletionController = Router() + +// --------------------------------------------------------------------------- +// GET /deletion/admin/audit/:publicId +// Admin-only: returns the anonymisation audit log for a user. +// --------------------------------------------------------------------------- + +deletionController.get( + '/admin/audit/:publicId', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalErrorResult( + DeletionUseCases.getAuditLog(user, req.params.publicId), + 'Failed to get deletion audit log', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// GET /deletion/admin/stats +// Admin-only: returns aggregate deletion statistics. +// --------------------------------------------------------------------------- + +deletionController.get( + '/admin/stats', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalErrorResult( + DeletionUseCases.getStats(user), + 'Failed to get deletion stats', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) diff --git a/apps/backend/src/app/servers/frontendWorker.ts b/apps/backend/src/app/servers/frontendWorker.ts index f7dcfb3e6..054dcf312 100644 --- a/apps/backend/src/app/servers/frontendWorker.ts +++ b/apps/backend/src/app/servers/frontendWorker.ts @@ -51,6 +51,12 @@ process.exit(1) } + // Deletion anonymisation job runs unconditionally + const { deletionAnonymisationJob } = await import( + '../../infrastructure/services/deletionAnonymisationJob.js' + ) + deletionAnonymisationJob.start() + const shutdown = async () => { logger.info('Shutting down frontend worker...') objectMappingArchiver.stop() @@ -59,6 +65,10 @@ '../../infrastructure/services/creditExpiryJob.js' ) creditExpiryJob.stop() + const { deletionAnonymisationJob } = await import( + '../../infrastructure/services/deletionAnonymisationJob.js' + ) + deletionAnonymisationJob.stop() await Rabbit.close() logger.info('Frontend worker shut down successfully') process.exit(0) diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts index 07ca75d76..5e3301ec4 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -85,6 +85,12 @@ export const config = { // it are rejected. Default: 10 minutes. intentExpiryMinutes: Number(env('INTENT_EXPIRY_MINUTES', '10')), }, + deletion: { + gracePeriodDays: Number(env('DELETION_GRACE_PERIOD_DAYS', '30')), + anonymisationCheckIntervalMs: Number( + env('DELETION_ANONYMISATION_CHECK_INTERVAL', '3600000'), + ), + }, params: { maxConcurrentUploads: Number(env('MAX_CONCURRENT_UPLOADS', '40')), maxAnonymousDownloadSize: Number( diff --git a/apps/backend/src/core/featureFlags/express.ts b/apps/backend/src/core/featureFlags/express.ts index 86d5208ed..b21b86cae 100644 --- a/apps/backend/src/core/featureFlags/express.ts +++ b/apps/backend/src/core/featureFlags/express.ts @@ -23,12 +23,17 @@ export const featureFlagMiddleware = export const getFeatureFlags = async (req: Request, res: Response) => { // If is authenticated, get the user from the request if (req.headers.authorization) { - const user = await handleAuth(req, res) - if (!user) { - return - } + try { + const user = await handleAuth(req, res) + if (!user) { + return + } - return FeatureFlagsUseCases.get(user) + return FeatureFlagsUseCases.get(user) + } catch { + // Auth failure — fall through to unauthenticated flags + return FeatureFlagsUseCases.get(null) + } } return FeatureFlagsUseCases.get(null) diff --git a/apps/backend/src/core/users/deletion.ts b/apps/backend/src/core/users/deletion.ts new file mode 100644 index 000000000..7decb4c41 --- /dev/null +++ b/apps/backend/src/core/users/deletion.ts @@ -0,0 +1,205 @@ +import { err, ok, Result } from 'neverthrow' +import { + DeletionAuditEntry, + DeletionRequest, + UserRole, + UserWithOrganization, +} from '@auto-drive/models' +import { deletionAuditRepository } from '../../infrastructure/repositories/deletionAudit.js' +import { accountsRepository } from '../../infrastructure/repositories/users/accounts.js' +import { purchasedCreditsRepository } from '../../infrastructure/repositories/users/purchasedCredits.js' +import { AuthManager } from '../../infrastructure/services/auth/index.js' +import { ForbiddenError, NotFoundError } from '../../errors/index.js' +import { createLogger } from '../../infrastructure/drivers/logger.js' +import { getDatabase } from '../../infrastructure/drivers/pg.js' + +const logger = createLogger('core:deletion') + +const anonymiseBackendData = async ( + request: DeletionRequest, +): Promise => { + const db = await getDatabase() + const publicId = request.userPublicId + + logger.info('Anonymising backend data for user %s', publicId) + + // Mark all object ownership records as deleted + const ownershipResult = await db.query<{ count: string }>( + `WITH updated AS ( + UPDATE object_ownership + SET marked_as_deleted = CURRENT_TIMESTAMP + WHERE oauth_provider = $1 AND oauth_user_id = $2 + AND marked_as_deleted IS NULL + RETURNING 1 + ) + SELECT COUNT(*) AS count FROM updated`, + [request.oauthProvider, request.oauthUserId], + ) + const ownershipCount = parseInt(ownershipResult.rows[0].count) + + await deletionAuditRepository.createEntry(publicId, 'object_ownership_anonymised', { + recordsUpdated: ownershipCount, + }) + + // Look up the user's account via their organization + const user = await AuthManager.getUserFromPublicId(publicId) + const organizationId = user.organizationId + + const account = organizationId + ? await accountsRepository.getByOrganizationId(organizationId) + : null + + let creditsExpired = 0 + let uploadBytesForfeited = 0n + let downloadBytesForfeited = 0n + + if (account) { + const activeCredits = await purchasedCreditsRepository.getActiveByAccountId( + account.id, + ) + + for (const credit of activeCredits) { + if (!credit.expired) { + creditsExpired++ + uploadBytesForfeited += credit.uploadBytesRemaining + downloadBytesForfeited += credit.downloadBytesRemaining + } + } + + if (creditsExpired > 0) { + await db.query( + `UPDATE purchased_credits + SET expired = TRUE, updated_at = NOW() + WHERE account_id = $1 AND expired = FALSE`, + [account.id], + ) + } + + await deletionAuditRepository.createEntry(publicId, 'credits_expired', { + creditsExpired, + uploadBytesForfeited: uploadBytesForfeited.toString(), + downloadBytesForfeited: downloadBytesForfeited.toString(), + }) + + // Expire all pending intents + const pendingIntentsResult = await db.query<{ count: string }>( + `WITH updated AS ( + UPDATE intents + SET status = 'expired' + WHERE user_public_id = $1 AND status = 'pending' + RETURNING 1 + ) + SELECT COUNT(*) AS count FROM updated`, + [publicId], + ) + const intentsExpired = parseInt(pendingIntentsResult.rows[0].count) + + await deletionAuditRepository.createEntry(publicId, 'intents_expired', { + intentsExpired, + }) + } + + await deletionAuditRepository.createEntry(publicId, 'anonymisation_completed', { + ownershipRecordsUpdated: ownershipCount, + creditsExpired, + uploadBytesForfeited: uploadBytesForfeited.toString(), + downloadBytesForfeited: downloadBytesForfeited.toString(), + }) + + logger.info( + 'Backend anonymisation complete for user %s: %d ownership records, %d credits expired', + publicId, + ownershipCount, + creditsExpired, + ) +} + +const processAnonymisation = async ( + request: DeletionRequest, +): Promise => { + logger.info( + 'Processing anonymisation for deletion request %s (user %s)', + request.id, + request.userPublicId, + ) + + try { + // Step 1: Mark as processing (race-safe via conditional update) + const processing = await AuthManager.markDeletionAsProcessing(request.id) + if (!processing) { + logger.warn( + 'Deletion request %s is no longer pending, skipping', + request.id, + ) + return + } + + // Step 2: Anonymise backend data + await anonymiseBackendData(request) + + // Step 3: Anonymise auth data + await AuthManager.executeAuthAnonymisation(request.id) + + // Step 4: Mark as completed + await AuthManager.markDeletionAsCompleted(request.id) + + logger.info('Anonymisation completed for deletion request %s', request.id) + } catch (error) { + logger.error( + 'Anonymisation failed for deletion request %s: %s', + request.id, + error instanceof Error ? error.message : String(error), + ) + + try { + await AuthManager.markDeletionAsFailed( + request.id, + `Anonymisation failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } catch (failError) { + logger.error( + 'Failed to mark deletion request %s as failed: %s', + request.id, + failError instanceof Error ? failError.message : String(failError), + ) + } + } +} + +const getAuditLog = async ( + executor: UserWithOrganization, + userPublicId: string, +): Promise> => { + if (executor.role !== UserRole.Admin) { + logger.warn( + 'Non-admin %s attempted to access deletion audit log', + executor.publicId, + ) + return err(new ForbiddenError('Admin access required')) + } + + const entries = await deletionAuditRepository.getByUser(userPublicId) + return ok(entries) +} + +const getStats = async ( + executor: UserWithOrganization, +): Promise< + Result< + { totalAnonymisations: number; recentAnonymisations: number }, + ForbiddenError + > +> => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const stats = await deletionAuditRepository.getStats() + return ok(stats) +} + +export const DeletionUseCases = { + processAnonymisation, + getAuditLog, + getStats, +} diff --git a/apps/backend/src/infrastructure/repositories/deletionAudit.ts b/apps/backend/src/infrastructure/repositories/deletionAudit.ts new file mode 100644 index 000000000..545a50a37 --- /dev/null +++ b/apps/backend/src/infrastructure/repositories/deletionAudit.ts @@ -0,0 +1,78 @@ +import { DeletionAuditEntry } from '@auto-drive/models' +import { getDatabase } from '../drivers/pg.js' + +type DBAuditEntry = { + id: string + user_public_id: string + action: string + details: Record | null + performed_at: Date +} + +const mapRow = (row: DBAuditEntry): DeletionAuditEntry => ({ + id: row.id, + userPublicId: row.user_public_id, + action: row.action, + details: row.details, + performedAt: row.performed_at, +}) + +const createEntry = async ( + userPublicId: string, + action: string, + details?: Record, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `INSERT INTO public.deletion_audit_log (user_public_id, action, details) + VALUES ($1, $2, $3) + RETURNING *`, + [userPublicId, action, details ? JSON.stringify(details) : null], + ) + + return mapRow(result.rows[0]) +} + +const getByUser = async ( + userPublicId: string, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `SELECT * FROM public.deletion_audit_log + WHERE user_public_id = $1 + ORDER BY performed_at DESC`, + [userPublicId], + ) + + return result.rows.map(mapRow) +} + +const getStats = async (): Promise<{ + totalAnonymisations: number + recentAnonymisations: number +}> => { + const db = await getDatabase() + const result = await db.query<{ + total: string + recent: string + }>( + `SELECT + COUNT(DISTINCT user_public_id) FILTER (WHERE action = 'anonymisation_completed') AS total, + COUNT(DISTINCT user_public_id) FILTER ( + WHERE action = 'anonymisation_completed' + AND performed_at >= CURRENT_TIMESTAMP - INTERVAL '30 days' + ) AS recent + FROM public.deletion_audit_log`, + ) + + return { + totalAnonymisations: parseInt(result.rows[0].total), + recentAnonymisations: parseInt(result.rows[0].recent), + } +} + +export const deletionAuditRepository = { + createEntry, + getByUser, + getStats, +} diff --git a/apps/backend/src/infrastructure/services/auth/index.ts b/apps/backend/src/infrastructure/services/auth/index.ts index 6d0edc26e..e6c26eda1 100644 --- a/apps/backend/src/infrastructure/services/auth/index.ts +++ b/apps/backend/src/infrastructure/services/auth/index.ts @@ -1,5 +1,5 @@ import { config } from '../../../config.js' -import { UserWithOrganization } from '@auto-drive/models' +import { DeletionRequest, UserWithOrganization } from '@auto-drive/models' const getUserFromAccessToken = async ( provider: string, @@ -56,8 +56,116 @@ const getUsersFromPublicIds = async ( return response.json() } +const getDeletionRequestsDue = async (): Promise => { + const response = await fetch( + `${config.authService.url}/users/admin/deletions/due`, + { + headers: { + Authorization: `Bearer ${config.authService.token}`, + }, + }, + ) + + if (!response.ok) { + throw new Error('Failed to fetch due deletion requests') + } + + return response.json() +} + +const markDeletionAsProcessing = async ( + requestId: string, +): Promise => { + const response = await fetch( + `${config.authService.url}/users/admin/deletions/${requestId}/process`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${config.authService.token}`, + }, + }, + ) + + // 404/409 means the request is no longer pending (race with another worker) + if (response.status === 404 || response.status === 409) { + return null + } + + if (!response.ok) { + throw new Error(`Failed to mark deletion ${requestId} as processing`) + } + + return response.json() +} + +const executeAuthAnonymisation = async ( + requestId: string, +): Promise => { + const response = await fetch( + `${config.authService.url}/users/admin/deletions/${requestId}/anonymise`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${config.authService.token}`, + }, + }, + ) + + if (!response.ok) { + throw new Error(`Failed to anonymise user for deletion ${requestId}`) + } +} + +const markDeletionAsCompleted = async ( + requestId: string, +): Promise => { + const response = await fetch( + `${config.authService.url}/users/admin/deletions/${requestId}/complete`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${config.authService.token}`, + }, + }, + ) + + if (!response.ok) { + throw new Error(`Failed to mark deletion ${requestId} as completed`) + } + + return response.json() +} + +const markDeletionAsFailed = async ( + requestId: string, + adminNotes?: string, +): Promise => { + const response = await fetch( + `${config.authService.url}/users/admin/deletions/${requestId}/fail`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.authService.token}`, + }, + body: JSON.stringify({ adminNotes }), + }, + ) + + if (!response.ok) { + throw new Error(`Failed to mark deletion ${requestId} as failed`) + } + + return response.json() +} + export const AuthManager = { getUserFromAccessToken, getUserFromPublicId, getUsersFromPublicIds, + getDeletionRequestsDue, + markDeletionAsProcessing, + executeAuthAnonymisation, + markDeletionAsCompleted, + markDeletionAsFailed, } diff --git a/apps/backend/src/infrastructure/services/deletionAnonymisationJob.ts b/apps/backend/src/infrastructure/services/deletionAnonymisationJob.ts new file mode 100644 index 000000000..53e585ed2 --- /dev/null +++ b/apps/backend/src/infrastructure/services/deletionAnonymisationJob.ts @@ -0,0 +1,57 @@ +import { config } from '../../config.js' +import { createLogger } from '../drivers/logger.js' +import { AuthManager } from './auth/index.js' +import { DeletionUseCases } from '../../core/users/deletion.js' +import { safeCallback } from '../../shared/utils/safe.js' + +const logger = createLogger('DeletionAnonymisationJob') + +const runAnonymisationCheck = async (): Promise => { + logger.info('Running deletion anonymisation check') + + try { + const dueRequests = await AuthManager.getDeletionRequestsDue() + + if (dueRequests.length === 0) { + logger.info('No deletion requests due for anonymisation') + return + } + + logger.info('%d deletion request(s) due for anonymisation', dueRequests.length) + + for (const request of dueRequests) { + await DeletionUseCases.processAnonymisation(request) + } + } catch (error) { + logger.error('Failed to run anonymisation check', error) + } + + logger.info('Deletion anonymisation check complete') +} + +let anonymisationInterval: NodeJS.Timeout | null = null + +const start = (): void => { + logger.info('Starting deletion anonymisation job', { + intervalMs: config.deletion.anonymisationCheckIntervalMs, + }) + safeCallback(runAnonymisationCheck)() + anonymisationInterval = setInterval( + safeCallback(runAnonymisationCheck), + config.deletion.anonymisationCheckIntervalMs, + ) +} + +const stop = (): void => { + logger.info('Stopping deletion anonymisation job') + if (anonymisationInterval) { + clearInterval(anonymisationInterval) + anonymisationInterval = null + } +} + +export const deletionAnonymisationJob = { + start, + stop, + _runAnonymisationCheck: runAnonymisationCheck, +} diff --git a/apps/frontend/src/app/[chain]/drive/admin/deletions/page.tsx b/apps/frontend/src/app/[chain]/drive/admin/deletions/page.tsx new file mode 100644 index 000000000..7906fa4d3 --- /dev/null +++ b/apps/frontend/src/app/[chain]/drive/admin/deletions/page.tsx @@ -0,0 +1,12 @@ +import { DeletionAdmin } from '@/components/views/DeletionAdmin'; +import { UserProtectedLayout } from '../../../../../components/layouts/UserProtectedLayout'; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/[chain]/drive/layout.tsx b/apps/frontend/src/app/[chain]/drive/layout.tsx index aac085b13..22698b139 100644 --- a/apps/frontend/src/app/[chain]/drive/layout.tsx +++ b/apps/frontend/src/app/[chain]/drive/layout.tsx @@ -11,6 +11,7 @@ import { SessionEnsurer } from '@/components/atoms/SessionEnsurer'; import { AutomaticLoginWrapper } from '../../../components/atoms/AutomaticLoginWrapper'; import { BannerNotifications } from '@/components/organisms/BannerNotifications'; import { ExpiryWarningBanner } from '../../../components/atoms/ExpiryWarningBanner'; +import { DeletionWarningBanner } from '../../../components/atoms/DeletionWarningBanner'; export default function AppLayout({ children, @@ -31,6 +32,7 @@ export default function AppLayout({
+
diff --git a/apps/frontend/src/components/atoms/DeletionWarningBanner.tsx b/apps/frontend/src/components/atoms/DeletionWarningBanner.tsx new file mode 100644 index 000000000..8785458a8 --- /dev/null +++ b/apps/frontend/src/components/atoms/DeletionWarningBanner.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { AuthService } from 'services/auth/auth'; +import { useUserStore } from 'globalStates/user'; +import { useDeletionStore } from 'globalStates/deletion'; +import toast from 'react-hot-toast'; + +export const DeletionWarningBanner = () => { + const [isCancelling, setIsCancelling] = useState(false); + const user = useUserStore((state) => state.user); + const deletionRequest = useDeletionStore((state) => state.deletionRequest); + const setDeletionRequest = useDeletionStore( + (state) => state.setDeletionRequest, + ); + + const fetchStatus = useCallback(async () => { + try { + const status = await AuthService.getDeletionStatus(); + setDeletionRequest(status); + } catch { + // User may not be authenticated yet + } + }, [setDeletionRequest]); + + useEffect(() => { + if (user) { + fetchStatus(); + } + }, [user, fetchStatus]); + + const handleCancel = useCallback(async () => { + setIsCancelling(true); + try { + await AuthService.cancelDeletion(); + toast.success('Account deletion cancelled'); + setDeletionRequest(null); + } catch { + toast.error('Failed to cancel deletion'); + } finally { + setIsCancelling(false); + } + }, [setDeletionRequest]); + + if (!deletionRequest) return null; + + const scheduledDate = new Date( + deletionRequest.scheduledAnonymisationAt, + ).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + return ( +
+
+ + Account deletion scheduled. + {' '} + + Your data will be anonymised on{' '} + {scheduledDate}. + +
+ +
+ ); +}; diff --git a/apps/frontend/src/components/organisms/SideNavBar/items.ts b/apps/frontend/src/components/organisms/SideNavBar/items.ts index 4d6d75176..b68d7a60b 100644 --- a/apps/frontend/src/components/organisms/SideNavBar/items.ts +++ b/apps/frontend/src/components/organisms/SideNavBar/items.ts @@ -8,6 +8,7 @@ import { SettingsIcon, MegaphoneIcon, FileTextIcon, + UserXIcon, } from 'lucide-react'; import { NetworkId, ROUTES } from '@auto-drive/ui'; import { SidebarSection } from './SideNavBarContent'; @@ -85,6 +86,12 @@ export const SIDEBAR_DEFINITION: SidebarSection[] = [ label: 'Terms of Use', requiresSession: true, }, + { + href: (networkId: NetworkId) => ROUTES.adminDeletions(networkId), + icon: UserXIcon, + label: 'Deletions', + requiresSession: true, + }, ], }, ]; diff --git a/apps/frontend/src/components/views/DeletionAdmin/index.tsx b/apps/frontend/src/components/views/DeletionAdmin/index.tsx new file mode 100644 index 000000000..d70fd29df --- /dev/null +++ b/apps/frontend/src/components/views/DeletionAdmin/index.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { + DeletionRequestStatus, + DeletionRequestWithUser, +} from '@auto-drive/models'; +import { Button } from '@auto-drive/ui'; +import toast from 'react-hot-toast'; +import { AuthService } from 'services/auth/auth'; + +const STATUS_LABELS: Record = { + [DeletionRequestStatus.Pending]: 'Pending', + [DeletionRequestStatus.Processing]: 'Processing', + [DeletionRequestStatus.Completed]: 'Completed', + [DeletionRequestStatus.Failed]: 'Failed', + [DeletionRequestStatus.Cancelled]: 'Cancelled', +}; + +const STATUS_COLORS: Record = { + [DeletionRequestStatus.Pending]: + 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', + [DeletionRequestStatus.Processing]: + 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + [DeletionRequestStatus.Completed]: + 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', + [DeletionRequestStatus.Failed]: + 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', + [DeletionRequestStatus.Cancelled]: + 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300', +}; + +export const DeletionAdmin = () => { + const [requests, setRequests] = useState([]); + const [statusFilter, setStatusFilter] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [editingNotes, setEditingNotes] = useState(null); + const [notesText, setNotesText] = useState(''); + + const fetchRequests = useCallback(async () => { + setIsLoading(true); + try { + const data = await AuthService.getAdminDeletionRequests( + statusFilter || undefined, + ); + setRequests(data); + } catch (error) { + toast.error('Failed to load deletion requests'); + console.error(error); + } finally { + setIsLoading(false); + } + }, [statusFilter]); + + useEffect(() => { + fetchRequests(); + }, [fetchRequests]); + + const handleSaveNotes = useCallback( + async (requestId: string) => { + try { + await AuthService.updateDeletionAdminNotes(requestId, notesText); + toast.success('Notes updated'); + setEditingNotes(null); + fetchRequests(); + } catch (error) { + toast.error('Failed to update notes'); + console.error(error); + } + }, + [notesText, fetchRequests], + ); + + const formatDate = (date: string | Date) => + new Date(date).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + + return ( +
+
+

Account Deletions

+

+ Users may request account deletion for GDPR/CCPA compliance. Once + requested, a 30-day grace period begins during which the user can + cancel. After the grace period, a background worker anonymises + their data: object ownership is soft-deleted, purchased credits and + pending intents are expired, and PII (username, avatar) is replaced + with a hashed placeholder. Data already stored on the DSN cannot be + removed. All actions are recorded in the audit log. +

+
+
+
+ + + +
+
+ + {isLoading ? ( +
Loading...
+ ) : requests.length === 0 ? ( +
+ No deletion requests found +
+ ) : ( +
+ + + + + + + + + + + + + {requests.map((req) => ( + + + + + + + + + ))} + +
UserStatusRequestedScheduledReasonAdmin Notes
+
+ {req.userPublicId.slice(0, 8)}... +
+ {req.oauthUsername && ( +
+ {req.oauthUsername} +
+ )} +
+ + {STATUS_LABELS[req.status as DeletionRequestStatus] ?? + req.status} + + + {formatDate(req.requestedAt)} + + {formatDate(req.scheduledAnonymisationAt)} + + {req.reason ?? '-'} + + {editingNotes === req.id ? ( +
+ setNotesText(e.target.value)} + className='w-40 rounded border border-gray-300 px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700' + /> + + +
+ ) : ( + + )} +
+
+ )} +
+ ); +}; diff --git a/apps/frontend/src/components/views/Profile/DeleteAccountModal.tsx b/apps/frontend/src/components/views/Profile/DeleteAccountModal.tsx new file mode 100644 index 000000000..7cfd93a17 --- /dev/null +++ b/apps/frontend/src/components/views/Profile/DeleteAccountModal.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { Fragment, useCallback, useState } from 'react'; +import { Dialog, Transition } from '@headlessui/react'; +import { Button } from '@auto-drive/ui'; +import toast from 'react-hot-toast'; +import { AuthService } from 'services/auth/auth'; +import { useDeletionStore } from 'globalStates/deletion'; + +type DeleteAccountModalProps = { + isOpen: boolean; + onClose: () => void; + onDeleted: () => void; +}; + +export const DeleteAccountModal = ({ + isOpen, + onClose, + onDeleted, +}: DeleteAccountModalProps) => { + const [confirmText, setConfirmText] = useState(''); + const [reason, setReason] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const setDeletionRequest = useDeletionStore( + (state) => state.setDeletionRequest, + ); + + const isConfirmed = confirmText === 'DELETE'; + + const handleSubmit = useCallback(async () => { + if (!isConfirmed || isSubmitting) return; + setIsSubmitting(true); + + try { + const request = await AuthService.requestDeletion(reason || undefined); + toast.success('Account deletion requested. You have 30 days to cancel.'); + setDeletionRequest(request); + onDeleted(); + onClose(); + } catch (error) { + toast.error('Failed to request account deletion'); + console.error(error); + } finally { + setIsSubmitting(false); + } + }, [isConfirmed, isSubmitting, reason, setDeletionRequest, onDeleted, onClose]); + + const handleClose = useCallback(() => { + setConfirmText(''); + setReason(''); + onClose(); + }, [onClose]); + + return ( + + + +
+ + +
+
+ + + + Delete Account + + +
+
+

This action will:

+
    +
  • + Start a 30-day grace period before your data is + anonymised +
  • +
  • Expire any unused purchased credits
  • +
  • Remove your personal information from our systems
  • +
  • + Data stored on the DSN cannot be removed and will persist +
  • +
+
+ +
+ You can cancel this request within 30 days from your + profile or via the banner at the top of the page. +
+ +
+ +