Skip to content
Open
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
22 changes: 22 additions & 0 deletions infra/scoped/iam.tf
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,28 @@ resource "aws_iam_role_policy_attachment" "deletion_lambda_dlq" {
policy_arn = aws_iam_policy.deletion_lambda_dlq.arn
}

data "aws_iam_policy_document" "patron_deletion_tracker_s3" {
statement {
actions = ["s3:PutObject"]
resources = ["arn:aws:s3:::wellcomecollection-temp-auth0-user-deletions/*"]
}

statement {
actions = ["s3:ListBucket"]
resources = ["arn:aws:s3:::wellcomecollection-temp-auth0-user-deletions"]
}
}

resource "aws_iam_policy" "patron_deletion_tracker_s3" {
name = "patron-deletion-tracker-s3-policy-${terraform.workspace}"
policy = data.aws_iam_policy_document.patron_deletion_tracker_s3.json
}

resource "aws_iam_role_policy_attachment" "patron_deletion_tracker_s3" {
role = module.patron_deletion_tracker_lambda.lambda_role.name
policy_arn = aws_iam_policy.patron_deletion_tracker_s3.arn
}

resource "aws_iam_role_policy_attachment" "identity_api_gateway_lambda_send_email" {
role = module.api_lambda.lambda_role.name
policy_arn = aws_iam_policy.send_email.arn
Expand Down
5 changes: 5 additions & 0 deletions packages/apps/patron-deletion-tracker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
"scripts": {
"build": "tsc --build tsconfig.build.json",
"remove-all-deleted-patrons": "ts-node ./scripts/remove-all-deleted-patrons.ts",
"bulk-delete-patrons": "ts-node ./scripts/bulk-delete-patrons.ts",
"clean": "rimraf ./lib",
"test": "jest"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.54.0",
"@weco/auth0-client": "*",
"@weco/sierra-client": "*",
"date-fns": "^2.28.0",
Expand All @@ -19,6 +21,9 @@
},
"devDependencies": {
"@aws-sdk/client-lambda": "^3.54.0",
"@aws-sdk/client-secrets-manager": "^3.54.0",
"@aws-sdk/client-ssm": "^3.54.0",
"@aws-sdk/util-utf8-node": "^3.54.0",
"@sinonjs/fake-timers": "^9.1.0",
"@types/aws-lambda": "^8.10.92",
"@types/node": "20",
Expand Down
295 changes: 295 additions & 0 deletions packages/apps/patron-deletion-tracker/scripts/bulk-delete-patrons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
import * as fs from 'fs';
import { Auth0Client, HttpAuth0Client } from '@weco/auth0-client';
import { ResponseStatus } from '@weco/identity-common';
import { getCreds } from '@weco/ts-aws';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { Lambda } from '@aws-sdk/client-lambda';
import yargs from 'yargs/yargs';
import ora from 'ora';
import { rateLimit } from '../src/rate-limits';

// Auth0 rate limits with headroom to avoid hitting hard limits
// See https://auth0.com/docs/troubleshoot/customer-support/operational-policies/rate-limit-policy/management-api-endpoint-rate-limits
const auth0RateLimits = {
minute: 150, // documented limit is 200
second: 15, // documented limit is 20
};

// Max concurrent operations
const MAX_CONCURRENCY = 5;

// Progress reporting interval
const PROGRESS_INTERVAL = 100;

type Options = {
environment: 'stage' | 'prod';
dryRun: boolean;
file?: string;
s3Uri?: string;
checkpointFile: string;
};

const parsePatronNumbers = (content: string): number[] => {
return content
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
.map((line) => {
const num = parseInt(line, 10);
if (isNaN(num)) {
throw new Error(`Invalid patron number: '${line}'`);
}
return num;
});
};

const readPatronNumbersFromFile = (filePath: string): number[] => {
const content = fs.readFileSync(filePath, 'utf-8');
return parsePatronNumbers(content);
};

const readPatronNumbersFromS3 = async (
s3Uri: string,
credentials: any
): Promise<number[]> => {
const match = s3Uri.match(/^s3:\/\/([^/]+)\/(.+)$/);
if (!match) {
throw new Error(
`Invalid S3 URI: ${s3Uri}. Expected format: s3://bucket/key`
);
}
const [, bucket, key] = match;
const s3 = new S3Client({ credentials });
const response = await s3.send(
new GetObjectCommand({ Bucket: bucket, Key: key })
);
const content = await response.Body!.transformToString();
return parsePatronNumbers(content);
};

const createAuth0Client = async (
environment: 'stage' | 'prod'
): Promise<Auth0Client> => {
const awsCredentials = await getCreds('identity', 'developer');

const lambda = new Lambda({ credentials: awsCredentials });
const functionName = `patron-deletion-tracker-${environment}`;
const response = await lambda.getFunction({ FunctionName: functionName });

const envVars = response.Configuration?.Environment?.Variables;
if (!envVars) {
throw new Error('Lambda function has no environment variables');
}

const {
AUTH0_API_ROOT,
AUTH0_API_AUDIENCE,
AUTH0_CLIENT_ID,
AUTH0_CLIENT_SECRET,
} = envVars;
if (
!AUTH0_API_ROOT ||
!AUTH0_API_AUDIENCE ||
!AUTH0_CLIENT_ID ||
!AUTH0_CLIENT_SECRET
) {
throw new Error('Missing AUTH0 environment variables in Lambda');
}

return new HttpAuth0Client(
AUTH0_API_ROOT,
AUTH0_API_AUDIENCE,
AUTH0_CLIENT_ID,
AUTH0_CLIENT_SECRET
);
};

const bulkDeletePatrons = async ({
environment,
dryRun,
file,
s3Uri,
checkpointFile,
}: Options) => {
const spinner = ora();

try {
// Load already-completed deletions from checkpoint file
const completed = new Set<number>();
if (fs.existsSync(checkpointFile)) {
const lines = fs.readFileSync(checkpointFile, 'utf-8').split('\n');
for (const line of lines) {
const n = parseInt(line.trim(), 10);
if (!isNaN(n)) completed.add(n);
}
if (completed.size > 0) {
console.log(
`Resuming: skipping ${completed.size} already-deleted patrons from ${checkpointFile}`
);
}
}
const checkpointStream = fs.createWriteStream(checkpointFile, {
flags: 'a',
});
// Read patron numbers from file or S3
let recordNumbers: number[];
if (s3Uri) {
spinner.start(`Reading patron numbers from ${s3Uri}...`);
const awsCredentials = await getCreds('identity', 'developer');
recordNumbers = await readPatronNumbersFromS3(s3Uri, awsCredentials);
spinner.succeed(`Read ${recordNumbers.length} patron numbers from S3`);
} else if (file) {
spinner.start(`Reading patron numbers from ${file}...`);
recordNumbers = readPatronNumbersFromFile(file);
spinner.succeed(`Read ${recordNumbers.length} patron numbers from file`);
} else {
throw new Error('Must specify either --file or --s3Uri');
}

const totalRecords = recordNumbers.length;

// Skip already-completed records
const pendingRecords = recordNumbers.filter((n) => !completed.has(n));
const skippedCount = totalRecords - pendingRecords.length;
if (skippedCount > 0) {
console.log(
`Skipping ${skippedCount} already-deleted records, ${pendingRecords.length} remaining.`
);
}

if (pendingRecords.length === 0) {
console.log('No patron numbers to process.');
return;
}

// Initialize Auth0 client
spinner.start('Initializing Auth0 client...');
const auth0Client = await createAuth0Client(environment);
spinner.succeed('Auth0 client initialized');

// Set up rate limiting
const rateLimitedQueue = rateLimit({
rateLimits: auth0RateLimits,
maxConcurrency: MAX_CONCURRENCY,
});

// Track progress
let deletedCount = 0;
let failedCount = 0;
const failedRecords: number[] = [];

// Create deletion tasks
const deletionTasks = pendingRecords.map((recordNumber, index) => {
return async () => {
try {
if (index % PROGRESS_INTERVAL === 0) {
console.log(
`[${new Date().toISOString()}] Progress: ${index}/${
pendingRecords.length
} records processed`
);
}

if (!dryRun) {
const response = await auth0Client.deleteUser(recordNumber);
if (response.status !== ResponseStatus.Success) {
throw new Error(`Error deleting user: ${response.message}`);
}
checkpointStream.write(`${recordNumber}\n`);
} else {
console.log(`[dry run] Would delete patron ${recordNumber}`);
}

deletedCount++;
} catch (error) {
failedCount++;
failedRecords.push(recordNumber);
console.error(
`Failed to delete patron ${recordNumber}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
};
});

// Process deletions with rate limiting
spinner.start(
`Processing ${pendingRecords.length} patron deletions (${
dryRun ? 'DRY RUN' : 'LIVE'
})...`
);
const startTime = Date.now();

await rateLimitedQueue(deletionTasks);

const duration = (Date.now() - startTime) / 1000;
spinner.succeed(
`Completed in ${duration.toFixed(1)}s (${(
totalRecords / duration
).toFixed(1)} records/sec)`
);

// Print summary
console.log('\n=== DELETION SUMMARY ===');
console.log(`Total records: ${totalRecords}`);
console.log(`Successfully deleted: ${deletedCount}`);
console.log(`Failed: ${failedCount}`);
console.log(`Duration: ${duration.toFixed(1)}s`);

if (failedRecords.length > 0) {
console.log('\n=== FAILED RECORDS ===');
console.log(failedRecords.join('\n'));
}

if (dryRun) {
console.log(
'\n⚠️ DRY RUN MODE: No patrons were actually deleted. Remove --dryRun flag to perform actual deletion.'
);
}
} catch (error) {
spinner.fail('Bulk deletion failed');
console.error(error);
process.exit(1);
}
};

const argv = yargs(process.argv.slice(2))
.options({
environment: {
choices: ['stage', 'prod'] as const,
default: 'stage' as const,
description: 'AWS environment (stage or prod)',
},
dryRun: {
type: 'boolean',
default: true,
description: 'Perform a dry run without actually deleting users',
},
file: {
type: 'string',
description:
'Path to local file containing patron record numbers (one per line)',
},
s3Uri: {
type: 'string',
description:
'S3 URI to file containing patron record numbers (e.g., s3://bucket/key.txt)',
},
checkpointFile: {
type: 'string',
default: 'deleted-patrons-checkpoint.txt',
description:
'File to record successful deletions for resuming interrupted runs',
},
})
.check((argv) => {
if (!argv.file && !argv.s3Uri) {
throw new Error('Must specify either --file or --s3Uri');
}
return true;
})
.help()
.alias('help', 'h').argv;

bulkDeletePatrons(argv as Options);
21 changes: 21 additions & 0 deletions packages/apps/patron-deletion-tracker/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { SierraClient } from '@weco/sierra-client';
import { Auth0Client } from '@weco/auth0-client';
import { ResponseStatus } from '@weco/identity-common';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { Context } from 'aws-lambda';
import { rateLimit } from './rate-limits';
import { getSierraQueryOptions, WindowConfig } from './windows';

const s3 = new S3Client({});
const DELETION_BUCKET = 'wellcomecollection-temp-auth0-user-deletions';

const auth0RateLimited = rateLimit({
// See "Write Users" in the table at
// https://auth0.com/docs/troubleshoot/customer-support/operational-policies/rate-limit-policy/management-api-endpoint-rate-limits#self-service-subscription-limits
Expand Down Expand Up @@ -53,6 +57,23 @@ export const createApp =
const nRecords = deletedRecordNumbers.length;
console.log(`Found ${nRecords} patron records to delete`);

// Write patron IDs to S3 as JSONL before running deletions
const s3Key = `deleted-patrons/${sierraQueryOptions.start.toISOString()}_${sierraQueryOptions.end.toISOString()}.jsonl`;
const jsonl = deletedRecordNumbers
.map((id) => JSON.stringify({ id }))
.join('\n');
await s3.send(
new PutObjectCommand({
Bucket: DELETION_BUCKET,
Key: s3Key,
Body: jsonl,
ContentType: 'application/x-ndjson',
})
);
console.log(
`Wrote ${nRecords} patron IDs to s3://${DELETION_BUCKET}/${s3Key}`
);

const remaining = new Set(deletedRecordNumbers);
try {
await auth0RateLimited(
Expand Down
Loading