Skip to content

Commit eb6f225

Browse files
authored
Merge pull request #18019 from mozilla/fxa-10568-fxa-10569-inactives
feat(scripts): add script to find and notifiy inactive accounts
2 parents 09096fb + 78e68b3 commit eb6f225

File tree

3 files changed

+317
-22
lines changed

3 files changed

+317
-22
lines changed
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
#!/usr/bin/env node -r esbuild-register
2+
3+
/* This Source Code Form is subject to the terms of the Mozilla Public
4+
* License, v. 2.0. If a copy of the MPL was not distributed with this
5+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6+
7+
/**
8+
* A script to start the inactive account deletion process. It should (per
9+
* current rqeuirements), for every inactive account:
10+
* - send a pre-deletion event to relevant RPs of the account
11+
* - enqueue a cloud task to send the first notification email
12+
*
13+
* This script relies on the same set of enivronment variables as the FxA auth
14+
* server.
15+
*/
16+
17+
import { Command } from 'commander';
18+
import { StatsD } from 'hot-shots';
19+
import { Container } from 'typedi';
20+
21+
import { parseDryRun } from '../lib/args';
22+
import { AppConfig, AuthFirestore, AuthLogger } from '../../lib/types';
23+
import appConfig from '../../config';
24+
import initLog from '../../lib/log';
25+
import initRedis from '../../lib/redis';
26+
import Token from '../../lib/tokens';
27+
import * as random from '../../lib/crypto/random';
28+
import { createDB } from '../../lib/db';
29+
import { setupFirestore } from '../../lib/firestore-db';
30+
import { CurrencyHelper } from '../../lib/payments/currencies';
31+
import { createStripeHelper, StripeHelper } from '../../lib/payments/stripe';
32+
import oauthDb from '../../lib/oauth/db';
33+
import { PlayBilling } from '../../lib/payments/iap/google-play';
34+
import { PlaySubscriptions } from '../../lib/payments/iap/google-play/subscriptions';
35+
import { AppleIAP } from '../../lib/payments/iap/apple-app-store/apple-iap';
36+
import { AppStoreSubscriptions } from '../../lib/payments/iap/apple-app-store/subscriptions';
37+
38+
import {
39+
accountWhereAndOrderByQueryBuilder,
40+
hasAccessToken,
41+
hasActiveRefreshToken,
42+
hasActiveSessionToken,
43+
IsActiveFnBuilder,
44+
setDateToUTC,
45+
} from './lib';
46+
47+
const defaultResultsLImit = 500000;
48+
const defaultInactiveByDate = () => {
49+
const inactiveBy = new Date();
50+
inactiveBy.setFullYear(inactiveBy.getFullYear() - 2);
51+
return inactiveBy;
52+
};
53+
54+
const init = async () => {
55+
const program = new Command();
56+
program
57+
.description(
58+
'Starts the inactive account deletion process by enqueuing the first email\n' +
59+
'notification for inactive accounts. This script allows segmenting the\n' +
60+
'accounts to search by account creation date. It also optionally accepts a\n' +
61+
'date at or after when an account is active in order to be excluded.\n\n' +
62+
'For example, to start the inactive deletion process on accounts created\n' +
63+
'between 2015-01-01 and 2015-01-31 where the account is not active after\n' +
64+
'2024-10-31:\n' +
65+
' enqueue-inactive-account-deletions.ts \\\n' +
66+
' --start-date 2015-01-01 \\\n' +
67+
' --end-date 2015-12-31 \\\n' +
68+
' --active-by-date 2024-10-31'
69+
)
70+
.option(
71+
'--dry-run [true|false]',
72+
'Print out the argument and configuration values that will be used in the execution of the script. Defaults to true.',
73+
true
74+
)
75+
.option(
76+
'--active-by-date [date]',
77+
'An account is considered active if it has any activity at or after this date. Optional. Defaults to two years ago from script execution time.',
78+
Date.parse
79+
)
80+
.option(
81+
'--start-date [date]',
82+
'Start of date range of account creation date, inclusive. Optional. Defaults to 2012-03-12.',
83+
Date.parse,
84+
'2012-03-12'
85+
)
86+
.option(
87+
'--end-date [date]',
88+
'End of date range of account creation date, inclusive.',
89+
Date.parse
90+
)
91+
.option(
92+
'--results-limit [number]',
93+
'The number of results per accounts DB query. Defaults to 500000.',
94+
parseInt,
95+
defaultResultsLImit
96+
);
97+
// @TODO add testing related parameters, such as UID(s), time between certain actions, etc.
98+
99+
program.parse(process.argv);
100+
101+
const isDryRun = parseDryRun(program.dryRun);
102+
const startDate = setDateToUTC(program.startDate);
103+
const endDate = setDateToUTC(program.endDate);
104+
const activeByDate = program.activeByDate
105+
? setDateToUTC(program.activeByDate)
106+
: defaultInactiveByDate();
107+
const startDateTimestamp = startDate.valueOf();
108+
const endDateTimestamp = endDate.valueOf() + 86400000; // next day for < comparisons
109+
const activeByDateTimestamp = activeByDate.valueOf();
110+
111+
const config = appConfig.getProperties();
112+
const log = initLog({
113+
...config.log,
114+
});
115+
const statsd = new StatsD({ ...config.statsd });
116+
const redis = initRedis(
117+
{ ...config.redis, ...config.redis.sessionTokens },
118+
log
119+
);
120+
const db = createDB(
121+
config,
122+
log,
123+
Token(log, config),
124+
random.base32(config.signinUnblock.codeLength)
125+
);
126+
const fxaDb = await db.connect(config, redis);
127+
128+
Container.set(AppConfig, config);
129+
Container.set(AuthLogger, log);
130+
131+
const authFirestore = setupFirestore(config);
132+
Container.set(AuthFirestore, authFirestore);
133+
const currencyHelper = new CurrencyHelper(config);
134+
Container.set(CurrencyHelper, currencyHelper);
135+
const stripeHelper = createStripeHelper(log, config, statsd);
136+
Container.set(StripeHelper, stripeHelper);
137+
const playBilling = Container.get(PlayBilling);
138+
const playSubscriptions = Container.get(PlaySubscriptions);
139+
const appleIap = Container.get(AppleIAP);
140+
const appStoreSubscriptions = Container.get(AppStoreSubscriptions);
141+
142+
if (isDryRun) {
143+
console.log(
144+
'Dry run mode is on. It is the default; use --dry-run=false when you are ready.'
145+
);
146+
console.log('Per DB query results limit: ', program.resultsLimit);
147+
// @TODO add more dry-run output
148+
return 0;
149+
}
150+
151+
const accountQueryBuilder = () =>
152+
accountWhereAndOrderByQueryBuilder(
153+
startDateTimestamp,
154+
endDateTimestamp,
155+
activeByDateTimestamp
156+
)
157+
.select('accounts.uid')
158+
.limit(program.resultsLimit);
159+
160+
const sessionTokensFn = fxaDb.sessions.bind(fxaDb);
161+
const refreshTokensFn = oauthDb.getRefreshTokensByUid.bind(oauthDb);
162+
const accessTokensFn = oauthDb.getAccessTokensByUid.bind(oauthDb);
163+
164+
const checkActiveSessionToken = async (uid: string) =>
165+
await hasActiveSessionToken(sessionTokensFn, uid, activeByDateTimestamp);
166+
const checkRefreshToken = async (uid: string) =>
167+
await hasActiveRefreshToken(refreshTokensFn, uid, activeByDateTimestamp);
168+
const checkAccessToken = async (uid: string) =>
169+
await hasAccessToken(accessTokensFn, uid);
170+
171+
const iapSubUids = new Set<string>();
172+
const playSubscriptionsCollection = await playBilling.purchaseDbRef().get();
173+
const appleSubscriptionsCollection = await appleIap.purchasesDbRef().get();
174+
((collections) => {
175+
for (const c of collections) {
176+
for (const purchaseRecordSnapshot of c.docs) {
177+
const x = purchaseRecordSnapshot.data();
178+
if (x.userId) {
179+
iapSubUids.add(x.userId);
180+
}
181+
}
182+
}
183+
})([playSubscriptionsCollection, appleSubscriptionsCollection]);
184+
185+
const hasIapSubscription = async (uid: string) =>
186+
iapSubUids.has(uid) &&
187+
((await playSubscriptions.getSubscriptions(uid)).length > 0 ||
188+
(await appStoreSubscriptions.getSubscriptions(uid)).length > 0);
189+
190+
const isActive = new IsActiveFnBuilder()
191+
.setActiveSessionTokenFn(checkActiveSessionToken)
192+
.setRefreshTokenFn(checkRefreshToken)
193+
.setAccessTokenFn(checkAccessToken)
194+
.setIapSubscriptionFn(hasIapSubscription)
195+
.build();
196+
197+
let hasMaxResultsCount = true;
198+
let totalRowsReturned = 0;
199+
let totalInactiveAccounts = 0;
200+
201+
while (hasMaxResultsCount) {
202+
const accountsQuery = accountQueryBuilder();
203+
accountsQuery.offset(totalRowsReturned);
204+
205+
const accounts = await accountsQuery;
206+
207+
if (!accounts.length) {
208+
hasMaxResultsCount = false;
209+
break;
210+
}
211+
212+
for (const accountRecord of accounts) {
213+
if (!(await isActive(accountRecord.uid))) {
214+
// @TODO add concurrency and rate limiting
215+
// @TODO enqueue first email notification
216+
217+
totalInactiveAccounts++;
218+
}
219+
}
220+
221+
hasMaxResultsCount = accounts.length === program.resultsLimit;
222+
totalRowsReturned += accounts.length;
223+
}
224+
225+
console.log(`Total accounts processed: ${totalRowsReturned}`);
226+
console.log(`Number of inactive accounts: ${totalInactiveAccounts}`);
227+
228+
return 0;
229+
};
230+
231+
if (require.main === module) {
232+
init()
233+
.catch((err: Error) => {
234+
console.error(err);
235+
process.exit(1);
236+
})
237+
.then((exitCode: number) => process.exit(exitCode));
238+
}

packages/fxa-auth-server/scripts/delete-inactive-accounts/get-inactive-account-uids.ts

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import { setupFirestore } from '../../lib/firestore-db';
3232
import { CurrencyHelper } from '../../lib/payments/currencies';
3333
import { createStripeHelper, StripeHelper } from '../../lib/payments/stripe';
3434
import oauthDb from '../../lib/oauth/db';
35-
import { Account } from 'fxa-shared/db/models/auth';
3635
import { PlayBilling } from '../../lib/payments/iap/google-play';
3736
import { PlaySubscriptions } from '../../lib/payments/iap/google-play/subscriptions';
3837
import { AppleIAP } from '../../lib/payments/iap/apple-app-store/apple-iap';
@@ -43,6 +42,7 @@ import {
4342
hasAccessToken,
4443
hasActiveRefreshToken,
4544
hasActiveSessionToken,
45+
IsActiveFnBuilder,
4646
setDateToUTC,
4747
} from './lib';
4848

@@ -180,12 +180,12 @@ const init = async () => {
180180

181181
const checkActiveSessionToken = collectPerfStatsOn(
182182
'Session Token Check',
183-
async (uid: string, activeByDateTimestamp: number) =>
183+
async (uid: string) =>
184184
await hasActiveSessionToken(sessionTokensFn, uid, activeByDateTimestamp)
185185
);
186186
const checkRefreshToken = collectPerfStatsOn(
187187
'Refresh Token Check',
188-
async (uid: string, activeByDateTimestamp: number) =>
188+
async (uid: string) =>
189189
await hasActiveRefreshToken(refreshTokensFn, uid, activeByDateTimestamp)
190190
);
191191
const checkAccessToken = collectPerfStatsOn(
@@ -227,26 +227,19 @@ const init = async () => {
227227

228228
const hasIapSubscription = collectPerfStatsOn(
229229
'Has IAP Check',
230-
async (accountRecord: Account) =>
231-
iapSubUids.has(accountRecord.uid) &&
232-
((await getPlaySubscriptions(accountRecord.uid)).length > 0 ||
233-
(await getAppleSubscriptions(accountRecord.uid)).length > 0)
230+
async (uid: string) =>
231+
iapSubUids.has(uid) &&
232+
((await getPlaySubscriptions(uid)).length > 0 ||
233+
(await getAppleSubscriptions(uid)).length > 0)
234234
);
235235

236-
const isActive = collectPerfStatsOn(
237-
'Active Status Check',
238-
async (accountRecord: Account) => {
239-
return (
240-
(await checkActiveSessionToken(
241-
accountRecord.uid,
242-
activeByDateTimestamp
243-
)) ||
244-
(await checkRefreshToken(accountRecord.uid, activeByDateTimestamp)) ||
245-
(await checkAccessToken(accountRecord.uid)) ||
246-
(await hasIapSubscription(accountRecord))
247-
);
248-
}
249-
);
236+
const _isActive = new IsActiveFnBuilder()
237+
.setActiveSessionTokenFn(checkActiveSessionToken)
238+
.setRefreshTokenFn(checkRefreshToken)
239+
.setAccessTokenFn(checkAccessToken)
240+
.setIapSubscriptionFn(hasIapSubscription)
241+
.build();
242+
const isActive = collectPerfStatsOn('Active Status Check', _isActive);
250243

251244
if (isDryRun) {
252245
const countQuery = accountWhereAndOrderBy().count({
@@ -284,7 +277,7 @@ const init = async () => {
284277
await queue.onSizeLessThan(concurrency * 5);
285278

286279
queue.add(async () => {
287-
if (!(await isActive(accountRecord))) {
280+
if (!(await isActive(accountRecord.uid))) {
288281
inactiveUids.push(accountRecord.uid);
289282
}
290283
});

packages/fxa-auth-server/scripts/delete-inactive-accounts/lib.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,67 @@ export const hasAccessToken = async (
102102
const accessTokens = await tokensFn(uid);
103103
return accessTokens.length > 0;
104104
};
105+
106+
export type ActiveConditionFn = (
107+
uid: string
108+
) => Promise<boolean> | Promise<Promise<boolean>>;
109+
110+
/**
111+
* This simple builder exists purely to make it clear, and in one place, what
112+
* conditions are required to consider an account (in)active, in addition to
113+
* the DB query conditions.
114+
*/
115+
export class IsActiveFnBuilder {
116+
// @TODO we need to add in the RP exclusion check here if it's not possible with MySQL
117+
118+
requiredFn = (message: string) => () => {
119+
throw new Error(message);
120+
};
121+
activeSessionTokenFn: ActiveConditionFn;
122+
refreshTokenFn: ActiveConditionFn;
123+
accessTokenFn: ActiveConditionFn;
124+
iapSubscriptionFn: ActiveConditionFn;
125+
126+
constructor() {
127+
this.activeSessionTokenFn = this.requiredFn(
128+
'A function to check for an active session token is required.'
129+
);
130+
this.refreshTokenFn = this.requiredFn(
131+
'A function to check for a refresh token is required.'
132+
);
133+
this.accessTokenFn = this.requiredFn(
134+
'A function to check for an access token is required.'
135+
);
136+
this.iapSubscriptionFn = this.requiredFn(
137+
'A function to check for an IAP subscription is required.'
138+
);
139+
}
140+
141+
setActiveSessionTokenFn(fn: ActiveConditionFn) {
142+
this.activeSessionTokenFn = fn;
143+
return this;
144+
}
145+
146+
setRefreshTokenFn(fn: ActiveConditionFn) {
147+
this.refreshTokenFn = fn;
148+
return this;
149+
}
150+
151+
setAccessTokenFn(fn: ActiveConditionFn) {
152+
this.accessTokenFn = fn;
153+
return this;
154+
}
155+
156+
setIapSubscriptionFn(fn: ActiveConditionFn) {
157+
this.iapSubscriptionFn = fn;
158+
return this;
159+
}
160+
161+
build() {
162+
return (async (uid: string) =>
163+
(await this.activeSessionTokenFn(uid)) ||
164+
(await this.refreshTokenFn(uid)) ||
165+
(await this.accessTokenFn(uid)) ||
166+
(await this.iapSubscriptionFn(uid))).bind(this);
167+
}
168+
}

0 commit comments

Comments
 (0)