-
Notifications
You must be signed in to change notification settings - Fork 229
feat(auth-server): add accountActivity table for per-client liveness #20620
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nshirley
wants to merge
1
commit into
main
Choose a base branch
from
FXA-13662
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+819
−13
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
18 changes: 18 additions & 0 deletions
18
packages/db-migrations/databases/fxa_oauth/patches/patch-036-037.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| /* This Source Code Form is subject to the terms of the Mozilla Public | ||
| * License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ | ||
|
|
||
| -- Account activity / liveness signal per (userId, clientId). | ||
| -- See FXA-13662. Written from the OAuth grant path; the scopes that were | ||
| -- requested/granted on each activity are tracked in accountActivityScopes | ||
| -- (see patch 37->38) keyed by the same (userId, clientId). | ||
| CREATE TABLE `accountActivity` ( | ||
| `userId` BINARY(16) NOT NULL, | ||
| `clientId` BINARY(8) NOT NULL, | ||
| `firstSeenAt` BIGINT UNSIGNED NOT NULL, | ||
| `lastSeenAt` BIGINT UNSIGNED NOT NULL, | ||
| PRIMARY KEY (`userId`, `clientId`), | ||
| KEY `idx_lastSeenAt` (`lastSeenAt`) | ||
| ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; | ||
|
|
||
| UPDATE dbMetadata SET value = '37' WHERE name = 'schema-patch-level'; | ||
7 changes: 7 additions & 0 deletions
7
packages/db-migrations/databases/fxa_oauth/patches/patch-037-036.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| /* This Source Code Form is subject to the terms of the Mozilla Public | ||
| * License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ | ||
|
|
||
| -- DROP TABLE IF EXISTS `accountActivity`; | ||
|
|
||
| -- UPDATE dbMetadata SET value = '36' WHERE name = 'schema-patch-level'; |
26 changes: 26 additions & 0 deletions
26
packages/db-migrations/databases/fxa_oauth/patches/patch-037-038.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| /* This Source Code Form is subject to the terms of the Mozilla Public | ||
| * License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ | ||
|
|
||
| -- One-to-many mapping of accountActivity rows to the scopes that were | ||
| -- requested/granted on each activity. Per-scope timestamps keep the | ||
| -- "when did this user last use Smart Window" / "Sync" / etc. | ||
| -- | ||
| -- For now we record the raw scope string; once the scopes | ||
| -- table is updated with the full set of allowed scopes we can migrate | ||
| -- this to a scopeId FK and drop the raw scope column. | ||
| CREATE TABLE `accountActivityScopes` ( | ||
| `userId` BINARY(16) NOT NULL, | ||
| `clientId` BINARY(8) NOT NULL, | ||
| `scope` VARCHAR(255) NOT NULL, | ||
| `firstSeenAt` BIGINT UNSIGNED NOT NULL, | ||
| `lastSeenAt` BIGINT UNSIGNED NOT NULL, | ||
| PRIMARY KEY (`userId`, `clientId`, `scope`), | ||
| -- Support global time-window scans; inactive-account deletion for example. | ||
| KEY `idx_lastSeenAt` (`lastSeenAt`), | ||
| -- Support per-scope queries, e.g. 'when did this user last use a browser service (Smart Window)?' | ||
| KEY `idx_scope_lastSeenAt` (`scope`, `lastSeenAt`), | ||
| FOREIGN KEY (`userId`, `clientId`) REFERENCES `accountActivity`(`userId`, `clientId`) ON DELETE CASCADE | ||
| ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; | ||
|
|
||
| UPDATE dbMetadata SET value = '38' WHERE name = 'schema-patch-level'; |
7 changes: 7 additions & 0 deletions
7
packages/db-migrations/databases/fxa_oauth/patches/patch-038-037.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| /* This Source Code Form is subject to the terms of the Mozilla Public | ||
| * License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ | ||
|
|
||
| -- DROP TABLE IF EXISTS `accountActivityScopes`; | ||
|
|
||
| -- UPDATE dbMetadata SET value = '37' WHERE name = 'schema-patch-level'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,3 @@ | ||
| { | ||
| "level": 36 | ||
| "level": 38 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
162 changes: 162 additions & 0 deletions
162
packages/fxa-auth-server/lib/oauth/account-activity.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| /* This Source Code Form is subject to the terms of the Mozilla Public | ||
| * License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ | ||
|
|
||
| import fxaShared from 'fxa-shared'; | ||
|
|
||
| import { recordActivity } from './account-activity'; | ||
|
|
||
| const ScopeSet = (fxaShared as any).oauth.scopes; | ||
|
|
||
| const SCOPE_OLDSYNC = 'https://identity.mozilla.com/apps/oldsync'; | ||
| const SCOPE_RELAY = 'https://identity.mozilla.com/apps/relay'; | ||
|
|
||
| const USER_ID = Buffer.from('0123456789abcdef0123456789abcdef', 'hex'); | ||
| const CLIENT_ID = Buffer.from('5882386c6d801776', 'hex'); | ||
| const CLIENT_ID_HEX = '5882386c6d801776'; | ||
| const NOW = 1_700_000_000_000; | ||
| const THROTTLE_MS = 86_400_000; | ||
|
|
||
| describe('recordActivity', () => { | ||
| let oauthDB: { recordAccountActivity: jest.Mock }; | ||
| let statsd: { increment: jest.Mock }; | ||
| let log: { warn: jest.Mock }; | ||
|
|
||
| function makeDeps() { | ||
| oauthDB = { recordAccountActivity: jest.fn().mockResolvedValue(undefined) }; | ||
| statsd = { increment: jest.fn() }; | ||
| log = { warn: jest.fn() }; | ||
| return { oauthDB, statsd, log }; | ||
| } | ||
|
|
||
| it('calls the DB with the user, client, and the scopes from the grant', async () => { | ||
| const deps = makeDeps(); | ||
| await recordActivity(deps, { | ||
| userId: USER_ID, | ||
| clientId: CLIENT_ID, | ||
| scopeSet: ScopeSet.fromArray([SCOPE_OLDSYNC, SCOPE_RELAY, 'profile']), | ||
| throttleMs: THROTTLE_MS, | ||
| grantType: 'fxa-credentials', | ||
| now: NOW, | ||
| }); | ||
|
|
||
| expect(deps.oauthDB.recordAccountActivity).toHaveBeenCalledTimes(1); | ||
| const [userId, clientId, scopes, now, throttleMs] = | ||
| deps.oauthDB.recordAccountActivity.mock.calls[0]; | ||
| expect(userId).toBe(USER_ID); | ||
| expect(clientId).toBe(CLIENT_ID); | ||
| expect(scopes).toEqual( | ||
| expect.arrayContaining([SCOPE_OLDSYNC, SCOPE_RELAY, 'profile']) | ||
| ); | ||
| expect(scopes).toHaveLength(3); | ||
| expect(now).toBe(NOW); | ||
| expect(throttleMs).toBe(THROTTLE_MS); | ||
| }); | ||
|
|
||
| it('passes an empty scopes array when the grant has no scopes', async () => { | ||
| const deps = makeDeps(); | ||
| await recordActivity(deps, { | ||
| userId: USER_ID, | ||
| clientId: CLIENT_ID, | ||
| scopeSet: null, | ||
| throttleMs: THROTTLE_MS, | ||
| grantType: 'authorization_code', | ||
| now: NOW, | ||
| }); | ||
|
|
||
| expect(deps.oauthDB.recordAccountActivity).toHaveBeenCalledWith( | ||
| USER_ID, | ||
| CLIENT_ID, | ||
| [], | ||
| NOW, | ||
| THROTTLE_MS | ||
| ); | ||
| }); | ||
|
|
||
| it('emits accountActivity.recorded with clientId and grantType tags on success', async () => { | ||
| const deps = makeDeps(); | ||
| await recordActivity(deps, { | ||
| userId: USER_ID, | ||
| clientId: CLIENT_ID, | ||
| scopeSet: ScopeSet.fromArray([SCOPE_OLDSYNC]), | ||
| throttleMs: THROTTLE_MS, | ||
| grantType: 'fxa-credentials', | ||
| now: NOW, | ||
| }); | ||
|
|
||
| expect(deps.statsd.increment).toHaveBeenCalledWith( | ||
| 'accountActivity.recorded', | ||
| { clientId: CLIENT_ID_HEX, grantType: 'fxa-credentials' } | ||
| ); | ||
| }); | ||
|
|
||
| it("tags an unspecified grantType as 'unknown' in the metric", async () => { | ||
| const deps = makeDeps(); | ||
| await recordActivity(deps, { | ||
| userId: USER_ID, | ||
| clientId: CLIENT_ID, | ||
| scopeSet: ScopeSet.fromArray([SCOPE_OLDSYNC]), | ||
| throttleMs: THROTTLE_MS, | ||
| now: NOW, | ||
| }); | ||
|
|
||
| expect(deps.statsd.increment).toHaveBeenCalledWith( | ||
| 'accountActivity.recorded', | ||
| { clientId: CLIENT_ID_HEX, grantType: 'unknown' } | ||
| ); | ||
| }); | ||
|
|
||
| it('swallows DB rejection and emits write_failed instead of recorded', async () => { | ||
| const deps = makeDeps(); | ||
| deps.oauthDB.recordAccountActivity.mockRejectedValueOnce( | ||
| new Error('connection lost') | ||
| ); | ||
|
|
||
| await expect( | ||
| recordActivity(deps, { | ||
| userId: USER_ID, | ||
| clientId: CLIENT_ID, | ||
| scopeSet: ScopeSet.fromArray([SCOPE_OLDSYNC]), | ||
| throttleMs: THROTTLE_MS, | ||
| grantType: 'refresh_token', | ||
| now: NOW, | ||
| }) | ||
| ).resolves.toBeUndefined(); | ||
|
|
||
| expect(deps.statsd.increment).toHaveBeenCalledTimes(1); | ||
| expect(deps.statsd.increment).toHaveBeenCalledWith( | ||
| 'accountActivity.write_failed', | ||
| { clientId: CLIENT_ID_HEX, grantType: 'refresh_token' } | ||
| ); | ||
| expect(deps.log.warn).toHaveBeenCalledWith( | ||
| 'accountActivity.write_failed', | ||
| expect.objectContaining({ | ||
| clientId: CLIENT_ID_HEX, | ||
| grantType: 'refresh_token', | ||
| error: 'connection lost', | ||
| }) | ||
| ); | ||
| }); | ||
|
|
||
| it('filters out empty/non-string scope values defensively', async () => { | ||
| const deps = makeDeps(); | ||
| // ScopeSet won't normally accept malformed values, but the helper | ||
| // defends in depth so a bad upstream change can't poison the mapping | ||
| // table with empty rows. | ||
| const fakeScopeSet = { | ||
| getScopeValues: () => | ||
| [SCOPE_OLDSYNC, '', null as any, undefined as any, 'profile'] as any, | ||
| }; | ||
| await recordActivity(deps, { | ||
| userId: USER_ID, | ||
| clientId: CLIENT_ID, | ||
| scopeSet: fakeScopeSet, | ||
| throttleMs: THROTTLE_MS, | ||
| grantType: 'authorization_code', | ||
| now: NOW, | ||
| }); | ||
|
|
||
| const [, , scopes] = deps.oauthDB.recordAccountActivity.mock.calls[0]; | ||
| expect(scopes).toEqual([SCOPE_OLDSYNC, 'profile']); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| /* This Source Code Form is subject to the terms of the Mozilla Public | ||
| * License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ | ||
|
|
||
| // Fire-and-forget writer for the accountActivity liveness signal (FXA-13662). | ||
| // Each OAuth grant produces one accountActivity row per (userId, clientId) | ||
| // and one row per granted scope in accountActivityScopes. Consumers identify | ||
| // the RP via the clientId column (JOIN clients.name for the human-readable | ||
| // name) and the requested scopes via the mapping table. | ||
|
|
||
| export interface ScopeSetLike { | ||
| getScopeValues(): Iterable<string>; | ||
| } | ||
|
|
||
| export interface AccountActivityOauthDB { | ||
| recordAccountActivity( | ||
| userId: Buffer | string, | ||
| clientId: Buffer | string, | ||
| scopes: string[], | ||
| now: number, | ||
| throttleMs: number | ||
| ): Promise<unknown>; | ||
| } | ||
|
|
||
| export interface AccountActivityStatsD { | ||
| increment(metric: string, tags?: Record<string, string>): void; | ||
| } | ||
|
|
||
| export interface AccountActivityLog { | ||
| warn(event: string, data?: Record<string, unknown>): void; | ||
| } | ||
|
|
||
| export interface AccountActivityDeps { | ||
| oauthDB: AccountActivityOauthDB; | ||
| statsd?: AccountActivityStatsD; | ||
| log?: AccountActivityLog; | ||
| } | ||
|
|
||
| export interface RecordActivityParams { | ||
| userId: Buffer | string; | ||
| clientId: Buffer | string; | ||
| scopeSet: ScopeSetLike | null | undefined; | ||
| /** Throttle window in ms; lastSeenAt is only updated if the existing row was last touched longer than this ago. */ | ||
| throttleMs: number; | ||
| /** For metric tagging. */ | ||
| grantType?: string; | ||
| /** For test determinism. */ | ||
| now?: number; | ||
| } | ||
|
|
||
| const hex = (v: Buffer | string): string => | ||
| Buffer.isBuffer(v) ? v.toString('hex') : v; | ||
|
|
||
| function extractScopes(scopeSet: ScopeSetLike | null | undefined): string[] { | ||
| if (!scopeSet || typeof scopeSet.getScopeValues !== 'function') return []; | ||
| return Array.from(scopeSet.getScopeValues()).filter( | ||
| (scope) => typeof scope === 'string' && scope.length > 0 | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should really hold this map in the DB. There's a several reasons:
|
||
| * Fire-and-forget write of an accountActivity row plus its scope mappings | ||
| * for a single OAuth grant. | ||
| * | ||
| * Never throws. Never blocks the response. On DB failure, increments | ||
| * `accountActivity.write_failed` and logs a warning; the grant still succeeds. | ||
| * | ||
| * Returns the awaited Promise so tests can assert on completion; the OAuth | ||
| * grant path does not await it. | ||
| */ | ||
| export async function recordActivity( | ||
| deps: AccountActivityDeps, | ||
| params: RecordActivityParams | ||
| ): Promise<void> { | ||
| const { oauthDB, statsd, log } = deps; | ||
| const { | ||
| userId, | ||
| clientId, | ||
| scopeSet, | ||
| throttleMs, | ||
| grantType, | ||
| now = Date.now(), | ||
| } = params; | ||
|
|
||
| // Grant validation upstream guarantees clientId is in the OAuth clients | ||
| // table, so the metric tag's cardinality is naturally bounded (matches the | ||
| // sibling oauth.rp.keys-jwe metric in token.js). No separate allowlist | ||
| // pass needed. | ||
| const clientIdHex = hex(clientId); | ||
| const scopes = extractScopes(scopeSet); | ||
| const metricTags = { | ||
| clientId: clientIdHex, | ||
| grantType: grantType || 'unknown', | ||
|
nshirley marked this conversation as resolved.
|
||
| }; | ||
|
|
||
| try { | ||
| await oauthDB.recordAccountActivity( | ||
| userId, | ||
| clientId, | ||
| scopes, | ||
| now, | ||
| throttleMs | ||
| ); | ||
| statsd?.increment('accountActivity.recorded', metricTags); | ||
| } catch (err) { | ||
| statsd?.increment('accountActivity.write_failed', metricTags); | ||
| log?.warn('accountActivity.write_failed', { | ||
| clientId: clientIdHex, | ||
| grantType, | ||
| error: err instanceof Error ? err.message : String(err), | ||
| }); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't there be an assertion that the current patch level is 36?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought there was, I had a bunch of conflicts so it probably got dropped - I'll add it back!