diff --git a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts index 937624a..7a4ce10 100644 --- a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts @@ -121,12 +121,25 @@ describe('AwsUserPoolsAdapter', () => { signInWith: 'email', passwordPolicy: '8+ characters with uppercase, lowercase, number, symbol', estimatedNumberOfUsers: 1, + userCount: 0, mfaConfiguration: 'OFF', deletionProtection: 'INACTIVE', appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}], }) }) + test('counts users itself, since the runtime leaves EstimatedNumberOfUsers at 0', async () => { + const {client} = stubCognito((command) => { + if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, EstimatedNumberOfUsers: 0}} + if (command instanceof ListUsersCommand) return command.input.PaginationToken ? {Users: [alice]} : {Users: [alice, alice], PaginationToken: 'page-2'} + return {} + }) + const resource = await new AwsUserPoolsAdapter(client).get(POOL_ID) + + expect(resource?.metadata.userCount).toBe(3) + expect(resource?.metadata.estimatedNumberOfUsers).toBe(0) + }) + test('summarises a relaxed password policy without character rules', async () => { const {client} = stubCognito((command) => { if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}}} diff --git a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts index 4e69695..3e9e893 100644 --- a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts +++ b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts @@ -57,6 +57,8 @@ import type { /** Cognito caps every list page at 60. */ const COGNITO_PAGE_LIMIT = 60 +/** Counting stops here so a very large pool cannot stall the pool list. */ +const USER_COUNT_CAP = 1000 const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ /** @@ -72,6 +74,8 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ * symbol, matching AWS. * - `InitiateAuth` with `USER_PASSWORD_AUTH` issues real tokens once a user's * password is permanent, so users created here can sign in immediately. + * - `EstimatedNumberOfUsers` stays 0 however many users exist (AWS also only + * updates it periodically), so `userCount` is counted with `ListUsers`. * - An admin action the user's state does not allow (confirming a confirmed * user) is `NotAuthorizedException`, surfaced here as a conflict because the * console caller is authorised and a 401 would end the console session. @@ -110,11 +114,12 @@ export class AwsUserPoolsAdapter implements CloudServiceAdapter { async get(id: string): Promise { try { - const [pool, clients] = await Promise.all([ + const [pool, clients, userCount] = await Promise.all([ this.cognito.send(new DescribeUserPoolCommand({UserPoolId: id})), this.listClients(id), + this.countUsers(id), ]) - return pool.UserPool ? toResource(pool.UserPool, clients) : null + return pool.UserPool ? toResource(pool.UserPool, clients, userCount) : null } catch (error) { if (isMissing(error)) return null throw error @@ -284,6 +289,17 @@ export class AwsUserPoolsAdapter implements CloudServiceAdapter { return listGroupNamesForUser(this.cognito, poolId, username) } + private async countUsers(poolId: string): Promise { + let count = 0 + let token: string | undefined + do { + const res = await this.cognito.send(new ListUsersCommand({UserPoolId: poolId, Limit: COGNITO_PAGE_LIMIT, ...(token ? {PaginationToken: token} : {})})) + count += res.Users?.length ?? 0 + token = res.PaginationToken || undefined + } while (token && count < USER_COUNT_CAP) + return Math.min(count, USER_COUNT_CAP) + } + private async listClients(poolId: string): Promise { const clients: UserPoolClientDescription[] = [] let nextToken: string | undefined @@ -423,7 +439,7 @@ function toSummaryResource(pool: UserPoolDescriptionType): CloudResource[] { }] } -function toResource(pool: UserPoolType, clients: UserPoolClientDescription[]): CloudResource { +function toResource(pool: UserPoolType, clients: UserPoolClientDescription[], userCount: number): CloudResource { const id = pool.Id ?? '' const policy = pool.Policies?.PasswordPolicy return { @@ -440,6 +456,7 @@ function toResource(pool: UserPoolType, clients: UserPoolClientDescription[]): C signInWith: pool.UsernameAttributes?.includes('email') ? 'email' : 'username', passwordPolicy: policy ? describePasswordPolicy(policy) : null, estimatedNumberOfUsers: pool.EstimatedNumberOfUsers ?? null, + userCount, mfaConfiguration: pool.MfaConfiguration ?? null, deletionProtection: pool.DeletionProtection ?? null, appClients: clients.map((client) => ({clientId: client.ClientId ?? '', clientName: client.ClientName ?? ''})), diff --git a/packages/api/src/cloud-spi/userPoolsSchema.ts b/packages/api/src/cloud-spi/userPoolsSchema.ts index ae19a69..4889416 100644 --- a/packages/api/src/cloud-spi/userPoolsSchema.ts +++ b/packages/api/src/cloud-spi/userPoolsSchema.ts @@ -86,7 +86,7 @@ export function awsUserPoolsSchema(): ServiceSchema { filters: userPoolFilters, columns: userPoolColumns, deleteImpact: [ - {path: 'metadata.estimatedNumberOfUsers', singular: 'user', plural: 'users'}, + {path: 'metadata.userCount', singular: 'user', plural: 'users'}, {path: 'metadata.appClients', singular: 'app client', plural: 'app clients'}, ], }