From 4156afccfe6b0c875b00b6156869de5b8a7d8b73 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Fri, 23 Oct 2026 12:12:40 -0400 Subject: [PATCH 1/3] feat(api): add Cognito user pools to the cloud explorer Adds a userpools catalog entry, schema and AwsUserPoolsAdapter, verified against Floci core: - list/get/create/delete user pools; create can set email or username sign-in, a relaxed password policy for local test users, and a public app client (on by default) whose id is surfaced in the pool metadata - users as the pool's child items: list (paged), get, create and delete; passwords are set permanent by default so users can sign in at once - map Cognito's HTTP-400 error names (UserNotFoundException, UsernameExistsException, AliasExistsException, InvalidPasswordException) - link Floci's cognito-idp service to the new page on the diagnostics page --- packages/api/package.json | 1 + .../adapter-aws/AwsUserPoolsAdapter.test.ts | 257 ++++++++++++++ .../src/adapter-aws/AwsUserPoolsAdapter.ts | 332 ++++++++++++++++++ .../api/src/adapter-aws/awsErrors.test.ts | 19 + packages/api/src/adapter-aws/awsErrors.ts | 4 + packages/api/src/aws.ts | 3 + packages/api/src/cloud-spi/serviceCatalog.ts | 7 + packages/api/src/cloud-spi/types.ts | 2 +- packages/api/src/cloud-spi/userPoolsSchema.ts | 83 +++++ packages/api/src/cloudProxy.ts | 2 + .../api/src/service/runtimeDiagnostics.ts | 1 + pnpm-lock.yaml | 224 +++++++++++- 12 files changed, 930 insertions(+), 5 deletions(-) create mode 100644 packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts create mode 100644 packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts create mode 100644 packages/api/src/cloud-spi/userPoolsSchema.ts diff --git a/packages/api/package.json b/packages/api/package.json index cd244c0..47a4453 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -18,6 +18,7 @@ "@aws-sdk/client-codebuild": "^3.1076.0", "@aws-sdk/client-codedeploy": "^3.1144.0", "@aws-sdk/client-codepipeline": "^3.1076.0", + "@aws-sdk/client-cognito-identity-provider": "^3.1146.0", "@aws-sdk/client-dynamodb": "^3.1088.0", "@aws-sdk/client-ec2": "^3.1076.0", "@aws-sdk/client-ecr": "^3.1076.0", diff --git a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts new file mode 100644 index 0000000..31df243 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts @@ -0,0 +1,257 @@ +import {describe, expect, test} from 'bun:test' +import { + AdminCreateUserCommand, + AdminDeleteUserCommand, + AdminGetUserCommand, + AdminSetUserPasswordCommand, + CreateUserPoolClientCommand, + CreateUserPoolCommand, + DeleteUserPoolCommand, + DescribeUserPoolCommand, + ListUserPoolClientsCommand, + ListUserPoolsCommand, + ListUsersCommand, + type CognitoIdentityProviderClient, +} from '@aws-sdk/client-cognito-identity-provider' +import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter' +import {ValidationError} from '../cloud-spi/errors' + +type SendResult = Record + +function stubCognito(handler: (command: object) => SendResult | Promise) { + const sent: object[] = [] + const client = { + async send(command: object) { + sent.push(command) + return handler(command) + }, + } as unknown as CognitoIdentityProviderClient + return {client, sent} +} + +function awsError(name: string): Error { + return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: 400}}) +} + +const POOL_ID = 'us-east-1_8e65d229a' +const CREATED = new Date('2026-10-23T16:01:22.000Z') + +const pool = { + Id: POOL_ID, + Name: 'orders-users', + Arn: `arn:aws:cognito-idp:us-east-1:000000000000:userpool/${POOL_ID}`, + Status: 'Enabled', + CreationDate: CREATED, + UsernameAttributes: ['email'], + EstimatedNumberOfUsers: 1, + MfaConfiguration: 'OFF', + DeletionProtection: 'INACTIVE', + Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: true, RequireLowercase: true, RequireNumbers: true, RequireSymbols: true}}, +} + +const alice = { + Username: 'aa36ecc1-ed8a-43b5-84e9-988398c17436', + Attributes: [ + {Name: 'sub', Value: 'aa36ecc1-ed8a-43b5-84e9-988398c17436'}, + {Name: 'email', Value: 'alice@example.com'}, + {Name: 'email_verified', Value: 'true'}, + ], + UserCreateDate: CREATED, + UserLastModifiedDate: CREATED, + Enabled: true, + UserStatus: 'CONFIRMED', +} + +function runtimeStub() { + return stubCognito((command) => { + if (command instanceof CreateUserPoolCommand) return {UserPool: pool} + if (command instanceof DescribeUserPoolCommand) return {UserPool: pool} + if (command instanceof ListUserPoolClientsCommand) return {UserPoolClients: [{ClientId: 'a8e5c7d38d3b4a1a97c9d822de', ClientName: 'orders-users-client', UserPoolId: POOL_ID}]} + if (command instanceof AdminCreateUserCommand) return {User: {...alice, UserStatus: 'FORCE_CHANGE_PASSWORD'}} + if (command instanceof AdminGetUserCommand) return {...alice, UserAttributes: alice.Attributes} + return {} + }) +} + +describe('AwsUserPoolsAdapter', () => { + test('lists every page of user pools and filters by name or id', async () => { + const {client, sent} = stubCognito((command) => { + if (!(command instanceof ListUserPoolsCommand)) return {} + return command.input.NextToken + ? {UserPools: [{Id: 'us-east-1_second', Name: 'billing', Status: 'Enabled', CreationDate: CREATED}]} + : {UserPools: [{Id: POOL_ID, Name: 'orders-users', Status: 'Enabled', CreationDate: CREATED}], NextToken: 'page-2'} + }) + const adapter = new AwsUserPoolsAdapter(client) + + expect((await adapter.list()).map((resource) => resource.id)).toEqual([POOL_ID, 'us-east-1_second']) + expect(sent.filter((command) => command instanceof ListUserPoolsCommand)).toHaveLength(2) + expect((await adapter.list({search: 'BILL'})).map((resource) => resource.name)).toEqual(['billing']) + expect((await adapter.list())[0]).toMatchObject({type: 'user-pool', region: 'us-east-1', status: 'Enabled', createdAt: CREATED.toISOString()}) + }) + + test('lists pools with the details the inspector shows', async () => { + const {client} = stubCognito((command) => { + if (command instanceof ListUserPoolsCommand) return {UserPools: [{Id: POOL_ID, Name: 'orders-users', Status: 'Enabled', CreationDate: CREATED}]} + if (command instanceof DescribeUserPoolCommand) return {UserPool: pool} + if (command instanceof ListUserPoolClientsCommand) return {UserPoolClients: [{ClientId: 'a8e5c7d38d3b4a1a97c9d822de', ClientName: 'orders-users-client'}]} + return {} + }) + + const [listed] = await new AwsUserPoolsAdapter(client).list() + expect(listed.metadata).toMatchObject({signInWith: 'email', appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}]}) + }) + + test('describes a pool with its sign-in method, password policy and app clients', async () => { + const resource = await new AwsUserPoolsAdapter(runtimeStub().client).get(POOL_ID) + + expect(resource?.metadata).toEqual({ + arn: pool.Arn, + signInWith: 'email', + passwordPolicy: '8+ characters with uppercase, lowercase, number, symbol', + estimatedNumberOfUsers: 1, + mfaConfiguration: 'OFF', + deletionProtection: 'INACTIVE', + appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}], + }) + }) + + 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}}}} + return {} + }) + + expect((await new AwsUserPoolsAdapter(client).get(POOL_ID))?.metadata.passwordPolicy).toBe('8+ characters') + }) + + test('returns null for a pool the runtime does not know', async () => { + const {client} = stubCognito(() => { throw awsError('ResourceNotFoundException') }) + + expect(await new AwsUserPoolsAdapter(client).get('us-east-1_missing')).toBeNull() + }) + + test('creates an email pool with a public app client by default', async () => { + const {client, sent} = runtimeStub() + await new AwsUserPoolsAdapter(client).create({values: {name: 'orders-users'}}) + + const createPool = sent.find((command) => command instanceof CreateUserPoolCommand) as CreateUserPoolCommand + expect(createPool.input).toEqual({PoolName: 'orders-users', UsernameAttributes: ['email'], AutoVerifiedAttributes: ['email']}) + const createClient = sent.find((command) => command instanceof CreateUserPoolClientCommand) as CreateUserPoolClientCommand + expect(createClient.input).toEqual({ + UserPoolId: POOL_ID, + ClientName: 'orders-users-client', + GenerateSecret: false, + ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'], + }) + }) + + test('creates a username pool with a relaxed password policy and no app client', async () => { + const {client, sent} = runtimeStub() + await new AwsUserPoolsAdapter(client).create({values: {name: 'testers', signInWith: 'username', passwordPolicy: 'relaxed', createAppClient: 'no'}}) + + const createPool = sent.find((command) => command instanceof CreateUserPoolCommand) as CreateUserPoolCommand + expect(createPool.input).toEqual({ + PoolName: 'testers', + Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}, + }) + expect(sent.some((command) => command instanceof CreateUserPoolClientCommand)).toBe(false) + }) + + test('removes the new pool when its app client cannot be created', async () => { + const {client, sent} = stubCognito((command) => { + if (command instanceof CreateUserPoolCommand) return {UserPool: pool} + if (command instanceof CreateUserPoolClientCommand) throw awsError('InvalidParameterException') + return {} + }) + + await expect(new AwsUserPoolsAdapter(client).create({values: {name: 'orders-users'}})).rejects.toThrow('InvalidParameterException') + expect((sent.at(-1) as DeleteUserPoolCommand).input).toEqual({UserPoolId: POOL_ID}) + }) + + test('rejects invalid create input before calling the runtime', async () => { + const {client, sent} = runtimeStub() + const adapter = new AwsUserPoolsAdapter(client) + + await expect(adapter.create({values: {}})).rejects.toBeInstanceOf(ValidationError) + await expect(adapter.create({values: {name: 'bad/name'}})).rejects.toBeInstanceOf(ValidationError) + await expect(adapter.create({values: {name: 'ok', signInWith: 'phone'}})).rejects.toBeInstanceOf(ValidationError) + expect(sent).toHaveLength(0) + }) + + test('deletes a pool by id', async () => { + const {client, sent} = runtimeStub() + await new AwsUserPoolsAdapter(client).delete(POOL_ID) + + expect(sent[0]).toBeInstanceOf(DeleteUserPoolCommand) + expect((sent[0] as DeleteUserPoolCommand).input).toEqual({UserPoolId: POOL_ID}) + }) +}) + +describe('AwsUserPoolsAdapter users', () => { + test('lists users as items keyed by username with their email and status', async () => { + const {client, sent} = stubCognito(() => ({Users: [alice], PaginationToken: 'next-page'})) + const page = await new AwsUserPoolsAdapter(client).items.listItems(POOL_ID, {limit: 500, cursor: 'this-page'}) + + expect((sent[0] as ListUsersCommand).input).toEqual({UserPoolId: POOL_ID, Limit: 60, PaginationToken: 'this-page'}) + expect(page.nextCursor).toBe('next-page') + expect(page.items).toEqual([{ + id: alice.Username, + collectionId: null, + timestamp: CREATED.toISOString(), + body: { + username: alice.Username, + email: 'alice@example.com', + emailVerified: true, + status: 'CONFIRMED', + enabled: true, + lastModifiedAt: CREATED.toISOString(), + attributes: {sub: alice.Username, email: 'alice@example.com', email_verified: 'true'}, + }, + metadata: {}, + }]) + }) + + test('creates a user whose password is permanent so it can sign in at once', async () => { + const {client, sent} = runtimeStub() + const user = await new AwsUserPoolsAdapter(client).items.putItem!(POOL_ID, {email: 'alice@example.com', password: ' Passw0rd! '}) + + expect((sent[0] as AdminCreateUserCommand).input).toEqual({ + UserPoolId: POOL_ID, + Username: 'alice@example.com', + UserAttributes: [{Name: 'email', Value: 'alice@example.com'}, {Name: 'email_verified', Value: 'true'}], + TemporaryPassword: ' Passw0rd! ', + MessageAction: 'SUPPRESS', + }) + expect((sent[1] as AdminSetUserPasswordCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username, Password: ' Passw0rd! ', Permanent: true}) + expect(user.body.status).toBe('CONFIRMED') + }) + + test('leaves the password temporary when asked', async () => { + const {client, sent} = runtimeStub() + await new AwsUserPoolsAdapter(client).items.putItem!(POOL_ID, {username: 'bob', password: 'Passw0rd!', permanent: false}) + + expect((sent[0] as AdminCreateUserCommand).input.Username).toBe('bob') + expect((sent[0] as AdminCreateUserCommand).input.UserAttributes).toEqual([]) + expect(sent.some((command) => command instanceof AdminSetUserPasswordCommand)).toBe(false) + }) + + test('rejects a user without a name, password or valid email', async () => { + const {client, sent} = runtimeStub() + const users = new AwsUserPoolsAdapter(client).items + + await expect(users.putItem!(POOL_ID, {password: 'Passw0rd!'})).rejects.toThrow('Provide a username or an email address') + await expect(users.putItem!(POOL_ID, {email: 'alice@example.com'})).rejects.toThrow('password is required') + await expect(users.putItem!(POOL_ID, {email: 'not-an-email', password: 'Passw0rd!'})).rejects.toThrow('email must be a valid email address') + await expect(users.putItem!(POOL_ID, {username: 'bob', password: 'Passw0rd!', permanent: 'yes'})).rejects.toThrow('permanent must be true or false') + expect(sent).toHaveLength(0) + }) + + test('returns null for a missing user and deletes by username', async () => { + const missing = stubCognito(() => { throw awsError('UserNotFoundException') }) + expect(await new AwsUserPoolsAdapter(missing.client).items.getItem!(POOL_ID, 'nobody')).toBeNull() + + const {client, sent} = runtimeStub() + await new AwsUserPoolsAdapter(client).items.deleteItem!(POOL_ID, alice.Username) + expect((sent[0] as AdminDeleteUserCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username}) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts new file mode 100644 index 0000000..e5e5b93 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts @@ -0,0 +1,332 @@ +import { + AdminCreateUserCommand, + AdminDeleteUserCommand, + AdminGetUserCommand, + AdminSetUserPasswordCommand, + CreateUserPoolClientCommand, + CreateUserPoolCommand, + DeleteUserPoolCommand, + DescribeUserPoolCommand, + ListUserPoolClientsCommand, + ListUserPoolsCommand, + ListUsersCommand, + type AttributeType, + type CognitoIdentityProviderClient, + type PasswordPolicyType, + type UserPoolClientDescription, + type UserPoolDescriptionType, + type UserPoolType, + type UserType, +} from '@aws-sdk/client-cognito-identity-provider' +import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections' +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import { + USER_POOL_NAME_PATTERN, + USER_POOL_PASSWORD_POLICIES, + USER_POOL_SIGN_IN, + awsUserPoolsSchema, +} from '../cloud-spi/userPoolsSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** Cognito caps every list page at 60. */ +const COGNITO_PAGE_LIMIT = 60 +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +/** + * Talks to Cognito user pools — verified against Floci core (community, `latest`). + * + * Notes from probing the runtime: + * - A missing pool is `ResourceNotFoundException` and a missing user is + * `UserNotFoundException`, both with HTTP **400**; `get` matches on the name. + * - With email sign-in the stored `Username` is the generated `sub`, not the + * address, so users are keyed by `Username` and the email is read from the + * attributes. Admin calls accept either form. + * - An omitted password policy defaults to requiring upper, lower, number and + * 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. + * + * Users are the pool's child items. The app client is created with the pool, as + * the console's create flow does, and its id is surfaced in the pool metadata. + */ +export class AwsUserPoolsAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'userpools' as const + readonly items: ItemStoreAdapter + + constructor(private readonly cognito: CognitoIdentityProviderClient) { + this.items = new UserStore(cognito) + } + + schema(): ServiceSchema { + return awsUserPoolsSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const pools: UserPoolDescriptionType[] = [] + let nextToken: string | undefined + + do { + const res = await this.cognito.send(new ListUserPoolsCommand({MaxResults: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})})) + pools.push(...(res.UserPools ?? [])) + nextToken = res.NextToken || undefined + } while (nextToken) + + // ListUserPools omits the sign-in method, password policy and app clients the + // inspector shows, so each match is described. Pools are few on a local runtime. + const matches = filterBySearch(pools.flatMap(toSummaryResource), query.search) + return Promise.all(matches.map(async (summary) => (await this.get(summary.id)) ?? summary)) + } + + async get(id: string): Promise { + try { + const [pool, clients] = await Promise.all([ + this.cognito.send(new DescribeUserPoolCommand({UserPoolId: id})), + this.listClients(id), + ]) + return pool.UserPool ? toResource(pool.UserPool, clients) : null + } catch (error) { + if (isMissing(error)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const name = requiredString(input.values.name, 'name') + if (!new RegExp(USER_POOL_NAME_PATTERN).test(name)) { + throw new ValidationError('name must be up to 128 letters, digits, spaces or + = , . @ _ -') + } + const signInWith = optionalOneOf(input.values.signInWith, USER_POOL_SIGN_IN, 'signInWith') ?? 'email' + const passwordPolicy = optionalOneOf(input.values.passwordPolicy, USER_POOL_PASSWORD_POLICIES, 'passwordPolicy') ?? 'default' + const createAppClient = optionalOneOf(input.values.createAppClient, ['yes', 'no'] as const, 'createAppClient') ?? 'yes' + + const created = await this.cognito.send(new CreateUserPoolCommand({ + PoolName: name, + ...(signInWith === 'email' ? {UsernameAttributes: ['email'], AutoVerifiedAttributes: ['email']} : {}), + ...(passwordPolicy === 'relaxed' + ? {Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}} + : {}), + })) + const id = created.UserPool?.Id + if (!id) throw new RuntimeError(`Cognito did not return the created user pool ${name}`) + + if (createAppClient === 'yes') { + try { + await this.cognito.send(new CreateUserPoolClientCommand({ + UserPoolId: id, + ClientName: `${name}-client`, + GenerateSecret: false, + ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'], + })) + } catch (error) { + // Without its client the pool is not what was asked for; do not leave it half-made. + await this.cognito.send(new DeleteUserPoolCommand({UserPoolId: id})).catch(() => undefined) + throw error + } + } + + const pool = await this.get(id) + if (!pool) throw new RuntimeError(`Cognito did not return the created user pool ${id}`) + return pool + } + + async delete(id: string): Promise { + await this.cognito.send(new DeleteUserPoolCommand({UserPoolId: id})) + } + + private async listClients(poolId: string): Promise { + const clients: UserPoolClientDescription[] = [] + let nextToken: string | undefined + + do { + const res = await this.cognito.send(new ListUserPoolClientsCommand({UserPoolId: poolId, MaxResults: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})})) + clients.push(...(res.UserPoolClients ?? [])) + nextToken = res.NextToken || undefined + } while (nextToken) + + return clients + } +} + +class UserStore implements ItemStoreAdapter { + constructor(private readonly cognito: CognitoIdentityProviderClient) {} + + async listItems(poolId: string, page: PageQuery = {}): Promise> { + const res = await this.cognito.send(new ListUsersCommand({ + UserPoolId: poolId, + Limit: Math.min(page.limit ?? COGNITO_PAGE_LIMIT, COGNITO_PAGE_LIMIT), + ...(page.cursor ? {PaginationToken: page.cursor} : {}), + })) + return {items: (res.Users ?? []).flatMap((user) => toUserItem(user.Username, user.Attributes, user)), nextCursor: res.PaginationToken || null} + } + + async getItem(poolId: string, username: string): Promise { + try { + const user = await this.cognito.send(new AdminGetUserCommand({UserPoolId: poolId, Username: username})) + return toUserItem(user.Username, user.UserAttributes, user)[0] ?? null + } catch (error) { + if (isMissing(error)) return null + throw error + } + } + + /** + * Creates a user that can sign in straight away: the password is set as + * permanent unless `permanent` is false, in which case Cognito will demand a + * new one at first sign-in (`FORCE_CHANGE_PASSWORD`). + */ + async putItem(poolId: string, body: Record): Promise { + const email = optionalString(body.email, 'email') + if (email && !EMAIL_PATTERN.test(email)) throw new ValidationError('email must be a valid email address') + const username = optionalString(body.username, 'username') ?? email + if (!username) throw new ValidationError('Provide a username or an email address') + // Not trimmed: leading or trailing spaces are part of a password. + const password = body.password + if (typeof password !== 'string' || !password) throw new ValidationError('password is required') + if (body.permanent !== undefined && typeof body.permanent !== 'boolean') throw new ValidationError('permanent must be true or false') + + const created = await this.cognito.send(new AdminCreateUserCommand({ + UserPoolId: poolId, + Username: username, + UserAttributes: email ? [{Name: 'email', Value: email}, {Name: 'email_verified', Value: 'true'}] : [], + TemporaryPassword: password, + MessageAction: 'SUPPRESS', + })) + const storedUsername = created.User?.Username + if (!storedUsername) throw new RuntimeError(`Cognito did not return the user created in ${poolId}`) + + if (body.permanent !== false) { + await this.cognito.send(new AdminSetUserPasswordCommand({UserPoolId: poolId, Username: storedUsername, Password: password, Permanent: true})) + } + + const user = await this.getItem(poolId, storedUsername) + if (!user) throw new RuntimeError(`Cognito did not return the user ${storedUsername} in ${poolId}`) + return user + } + + async deleteItem(poolId: string, username: string): Promise { + await this.cognito.send(new AdminDeleteUserCommand({UserPoolId: poolId, Username: username})) + } +} + +function toSummaryResource(pool: UserPoolDescriptionType): CloudResource[] { + if (!pool.Id) return [] + return [{ + id: pool.Id, + name: pool.Name ?? pool.Id, + cloud: 'aws', + service: 'userpools', + type: 'user-pool', + region: regionFromPoolId(pool.Id), + createdAt: pool.CreationDate ? pool.CreationDate.toISOString() : null, + status: pool.Status ?? null, + metadata: {}, + }] +} + +function toResource(pool: UserPoolType, clients: UserPoolClientDescription[]): CloudResource { + const id = pool.Id ?? '' + const policy = pool.Policies?.PasswordPolicy + return { + id, + name: pool.Name ?? id, + cloud: 'aws', + service: 'userpools', + type: 'user-pool', + region: regionFromPoolId(id), + createdAt: pool.CreationDate ? pool.CreationDate.toISOString() : null, + status: pool.Status ?? null, + metadata: { + arn: pool.Arn, + signInWith: pool.UsernameAttributes?.includes('email') ? 'email' : 'username', + passwordPolicy: policy ? describePasswordPolicy(policy) : null, + estimatedNumberOfUsers: pool.EstimatedNumberOfUsers ?? null, + mfaConfiguration: pool.MfaConfiguration ?? null, + deletionProtection: pool.DeletionProtection ?? null, + appClients: clients.map((client) => ({clientId: client.ClientId ?? '', clientName: client.ClientName ?? ''})), + }, + } +} + +function toUserItem( + username: string | undefined, + attributes: AttributeType[] | undefined, + user: Pick, +): ChildItem[] { + if (!username) return [] + const attributeMap = Object.fromEntries((attributes ?? []).flatMap((attribute) => attribute.Name ? [[attribute.Name, attribute.Value ?? '']] : [])) + return [{ + id: username, + collectionId: null, + timestamp: user.UserCreateDate ? user.UserCreateDate.toISOString() : null, + body: { + username, + email: attributeMap.email ?? null, + emailVerified: attributeMap.email_verified === 'true', + status: user.UserStatus ?? null, + enabled: user.Enabled ?? true, + lastModifiedAt: user.UserLastModifiedDate ? user.UserLastModifiedDate.toISOString() : null, + attributes: attributeMap, + }, + metadata: {}, + }] +} + +/** One readable line for the inspector, e.g. "8+ characters with uppercase, number". */ +function describePasswordPolicy(policy: PasswordPolicyType): string { + const required = [ + policy.RequireUppercase && 'uppercase', + policy.RequireLowercase && 'lowercase', + policy.RequireNumbers && 'number', + policy.RequireSymbols && 'symbol', + ].filter((rule): rule is string => Boolean(rule)) + const length = `${policy.MinimumLength ?? 8}+ characters` + return required.length ? `${length} with ${required.join(', ')}` : length +} + +/** Pool ids are `_`. */ +function regionFromPoolId(id: string): string | null { + const separator = id.indexOf('_') + return separator > 0 ? id.slice(0, separator) : null +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + + return resources.filter( + (resource) => + resource.name.toLowerCase().includes(normalized) || resource.id.toLowerCase().includes(normalized), + ) +} + +function isMissing(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const name = (error as {name?: string}).name + return name === 'ResourceNotFoundException' || name === 'UserNotFoundException' +} + +function requiredString(value: unknown, field: string): string { + const trimmed = optionalString(value, field) + if (!trimmed) throw new ValidationError(`${field} is required`) + return trimmed +} + +function optionalString(value: unknown, field: string): string | undefined { + if (value === undefined || value === null || value === '') return undefined + if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`) + return value.trim() || undefined +} + +function optionalOneOf(value: unknown, allowed: readonly T[], field: string): T | undefined { + const text = optionalString(value, field) + if (text === undefined) return undefined + if (!(allowed as readonly string[]).includes(text)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`) + return text as T +} diff --git a/packages/api/src/adapter-aws/awsErrors.test.ts b/packages/api/src/adapter-aws/awsErrors.test.ts index ee61c9a..dcceb0e 100644 --- a/packages/api/src/adapter-aws/awsErrors.test.ts +++ b/packages/api/src/adapter-aws/awsErrors.test.ts @@ -127,3 +127,22 @@ describe('logs errors carry HTTP 400 and must not fall through to it', () => { expect(mapped?.status).toBe(409) }) }) + +/** + * Cognito reports every failure with HTTP 400, so these names must be mapped + * explicitly or a missing user would read as a validation error. + */ +describe('cognito errors carry HTTP 400 and keep their meaning', () => { + const cases: Array<[string, CloudErrorStatus]> = [ + ['UserNotFoundException', 404], + ['UsernameExistsException', 409], + ['AliasExistsException', 409], + ['InvalidPasswordException', 400], + ] + + for (const [name, status] of cases) { + test(`${name} maps to ${status}`, () => { + expect(mapAwsSdkError(sdkError(name, {$metadata: {httpStatusCode: 400}}))?.status).toBe(status) + }) + } +}) diff --git a/packages/api/src/adapter-aws/awsErrors.ts b/packages/api/src/adapter-aws/awsErrors.ts index 39c138f..1776061 100644 --- a/packages/api/src/adapter-aws/awsErrors.ts +++ b/packages/api/src/adapter-aws/awsErrors.ts @@ -58,6 +58,8 @@ const CONFLICT_NAMES = new Set([ 'ConcurrentModificationException', 'IncorrectState', 'DeleteConflictException', + 'UsernameExistsException', + 'AliasExistsException', ]) const ACCESS_DENIED_NAMES = new Set([ @@ -90,6 +92,7 @@ const VALIDATION_NAMES = new Set([ 'InvalidBucketName', 'InvalidArgumentException', 'InvalidParameterException', + 'InvalidPasswordException', ]) const NOT_FOUND_NAMES = new Set([ @@ -119,6 +122,7 @@ const NOT_FOUND_NAMES = new Set([ 'InvalidSubnetID.NotFound', 'InvalidGroupId.NotFound', 'InvalidAMIID.NotFound', + 'UserNotFoundException', ]) const NOT_IMPLEMENTED_NAMES = new Set([ diff --git a/packages/api/src/aws.ts b/packages/api/src/aws.ts index 98dd31a..054f448 100644 --- a/packages/api/src/aws.ts +++ b/packages/api/src/aws.ts @@ -30,6 +30,7 @@ import { SESv2Client } from "@aws-sdk/client-sesv2"; import { CloudWatchClient } from "@aws-sdk/client-cloudwatch"; import { ElastiCacheClient } from "@aws-sdk/client-elasticache"; import { LightsailClient } from "@aws-sdk/client-lightsail"; +import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider"; const endpoint = process.env.FLOCI_ENDPOINT; const region = process.env.AWS_REGION || "us-east-1"; @@ -91,6 +92,7 @@ export type AwsClients = { cloudwatch: CloudWatchClient; elasticache: ElastiCacheClient; lightsail: LightsailClient; + cognito: CognitoIdentityProviderClient; }; export type AwsClientName = keyof AwsClients; @@ -136,6 +138,7 @@ function buildClients(accountId: string): AwsClients { cloudwatch: new CloudWatchClient(base), elasticache: new ElastiCacheClient(base), lightsail: new LightsailClient(base), + cognito: new CognitoIdentityProviderClient(base), }; } diff --git a/packages/api/src/cloud-spi/serviceCatalog.ts b/packages/api/src/cloud-spi/serviceCatalog.ts index 2547543..f21adf8 100644 --- a/packages/api/src/cloud-spi/serviceCatalog.ts +++ b/packages/api/src/cloud-spi/serviceCatalog.ts @@ -239,6 +239,13 @@ export const SERVICE_CATALOG = { // AWS retains its dedicated page while Azure and GCP use Cloud Explorer. routeByCloud: {aws: '/secretsmanager'}, }, + userpools: { + displayName: 'User Directories', + displayNameByCloud: {aws: 'Cognito'}, + iconKey: 'userpools', + group: 'Security', + order: 7, + }, iac: { displayName: 'Infrastructure as Code', displayNameByCloud: {aws: 'CloudFormation'}, diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index fd68405..42e94fb 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -230,7 +230,7 @@ export type KnownResourceType = | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' | 'secret' | 'iam-user' | 'servicebus-namespace' | 'queue' | 'fifo-queue' | 'topic' | 'event-bus' | 'rest-api' | 'stack' | 'email' | 'sql-server' - | 'postgres-flexible-server' | 'load-balancer' | 'state-machine' | 'scheduler-job' | 'key' | 'parameter' | 'cloud-run-service' | 'log-group' + | 'postgres-flexible-server' | 'load-balancer' | 'state-machine' | 'scheduler-job' | 'key' | 'parameter' | 'cloud-run-service' | 'log-group' | 'user-pool' export interface CloudResource { id: string diff --git a/packages/api/src/cloud-spi/userPoolsSchema.ts b/packages/api/src/cloud-spi/userPoolsSchema.ts new file mode 100644 index 0000000..097dfe9 --- /dev/null +++ b/packages/api/src/cloud-spi/userPoolsSchema.ts @@ -0,0 +1,83 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +/** Cognito's own limit and character set for user pool names. */ +export const USER_POOL_NAME_PATTERN = '^[\\w\\s+=,.@-]{1,128}$' +export const USER_POOL_SIGN_IN = ['email', 'username'] as const +export const USER_POOL_PASSWORD_POLICIES = ['default', 'relaxed'] as const + +const userPoolColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'id', label: 'Pool ID', format: 'code'}, + {name: 'status', label: 'Status', format: 'badge'}, + {name: 'createdAt', label: 'Created', format: 'datetime'}, +] + +const userPoolFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}] + +export function awsUserPoolsSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'userpools', + displayName: 'Amazon Cognito user pools', + fields: [ + { + name: 'name', + label: 'Pool name', + type: 'text', + required: true, + validation: {pattern: USER_POOL_NAME_PATTERN, message: 'Use up to 128 letters, digits, spaces or + = , . @ _ -'}, + }, + { + name: 'signInWith', + label: 'Sign in with', + type: 'select', + required: false, + defaultValue: 'email', + description: 'Email makes the address the sign-in name; username keeps a separate user name.', + options: [{label: 'Email address', value: 'email'}, {label: 'Username', value: 'username'}], + }, + { + name: 'passwordPolicy', + label: 'Password policy', + type: 'select', + required: false, + defaultValue: 'default', + description: 'Relaxed only requires 8 characters, which keeps local test users quick to create.', + options: [ + {label: 'AWS default (upper, lower, number, symbol)', value: 'default'}, + {label: 'Relaxed (8+ characters)', value: 'relaxed'}, + ], + }, + { + name: 'createAppClient', + label: 'App client', + type: 'select', + required: false, + defaultValue: 'yes', + description: 'A public client (no secret) with username/password and refresh-token sign-in, named -client.', + options: [{label: 'Create an app client', value: 'yes'}, {label: 'No app client', value: 'no'}], + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List user pools', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create user pool', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete user pool', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect user pool', enabled: true, status: 'available', runtimeRequired: true}, + ], + itemActions: [ + {name: 'list', label: 'List users', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'get', label: 'Get user', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'put', label: 'Create user', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete user', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: userPoolFilters, + columns: userPoolColumns, + } +} + +export function userPoolsSchemaFor(cloud: CloudProvider): ServiceSchema | null { + return cloud === 'aws' ? awsUserPoolsSchema() : null +} diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index 53647a8..0d12799 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -47,6 +47,7 @@ import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter' import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter' import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter' import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter' +import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter' import {awsClientsForAccount, resolveAccountId} from './aws' import {createEc2Service} from './services/ec2' import {createEksService} from './services/eks' @@ -96,6 +97,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap new AwsKinesisAdapter(clients.kinesis), new AwsElastiCacheAdapter(clients.elasticache), new AwsLightsailAdapter(clients.lightsail), + new AwsUserPoolsAdapter(clients.cognito), new AzureStorageAdapter(), new AzureServiceBusAdapter(), new AzureDatabaseAdapter(), diff --git a/packages/api/src/service/runtimeDiagnostics.ts b/packages/api/src/service/runtimeDiagnostics.ts index c8076c4..375c6eb 100644 --- a/packages/api/src/service/runtimeDiagnostics.ts +++ b/packages/api/src/service/runtimeDiagnostics.ts @@ -25,6 +25,7 @@ const AWS_CONSOLE_SERVICES: Record = { codebuild: ['codebuild'], codedeploy: ['codedeploy'], codepipeline: ['codepipeline'], + 'cognito-idp': ['userpools'], dynamodb: ['nosql'], ec2: ['compute', 'networking'], ecr: ['ecr'], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 412111c..f80d77a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ importers: '@aws-sdk/client-codepipeline': specifier: ^3.1076.0 version: 3.1144.0 + '@aws-sdk/client-cognito-identity-provider': + specifier: ^3.1146.0 + version: 3.1156.0 '@aws-sdk/client-dynamodb': specifier: ^3.1088.0 version: 3.1112.0 @@ -318,6 +321,10 @@ packages: resolution: {integrity: sha512-IhmU0tlv959L/1DjetFai1JcYfm1TrR+ZshokjrYe1zYk5Vt51nv07upMNywYH9BcArEPAv6pHUZXYFQITVEIA==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-cognito-identity-provider@3.1156.0': + resolution: {integrity: sha512-R+fVyawDRELzLTMKYIrzGtqku6OULvRnk1SQDR1QQhyVnvZXmJDNuKlcvChl0pEFllpO69Rce9TiZwzMAe/2oQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-dynamodb@3.1112.0': resolution: {integrity: sha512-Dborj6oKxgV9htN0jpdqGA9mTVOAPtYrrNI9ro67JNojtqIp1qHkdTkz1RkVi5Um2meV0qKVvhxLXRsI/S9RYQ==} engines: {node: '>=20.0.0'} @@ -441,6 +448,10 @@ packages: resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.978.5': + resolution: {integrity: sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.54': resolution: {integrity: sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A==} engines: {node: '>=20.0.0'} @@ -465,6 +476,10 @@ packages: resolution: {integrity: sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.76': + resolution: {integrity: sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.56': resolution: {integrity: sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw==} engines: {node: '>=20.0.0'} @@ -489,6 +504,10 @@ packages: resolution: {integrity: sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.78': + resolution: {integrity: sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.61': resolution: {integrity: sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw==} engines: {node: '>=20.0.0'} @@ -509,6 +528,10 @@ packages: resolution: {integrity: sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.973.21': + resolution: {integrity: sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.973.7': resolution: {integrity: sha512-2CefB8cCxDu52P24B8Ay93/cTT199bcSvNHQ8e2f4BjSCF83yErBnTIZEBo0VeIgCfmw+PJKFUXnlQWxm2dkug==} engines: {node: '>=20.0.0'} @@ -537,6 +560,10 @@ packages: resolution: {integrity: sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.83': + resolution: {integrity: sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.63': resolution: {integrity: sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw==} engines: {node: '>=20.0.0'} @@ -565,6 +592,10 @@ packages: resolution: {integrity: sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.88': + resolution: {integrity: sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.54': resolution: {integrity: sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg==} engines: {node: '>=20.0.0'} @@ -589,6 +620,10 @@ packages: resolution: {integrity: sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.76': + resolution: {integrity: sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.60': resolution: {integrity: sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag==} engines: {node: '>=20.0.0'} @@ -609,6 +644,10 @@ packages: resolution: {integrity: sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.973.20': + resolution: {integrity: sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.973.6': resolution: {integrity: sha512-DobZggy3K49xdCpjeyMou0FQhkoYbluVGNydL6D+lcxF8GoAsttFX0xnH5GmiQ89We5dB6TRpW+CD/VowBH6HQ==} engines: {node: '>=20.0.0'} @@ -637,6 +676,10 @@ packages: resolution: {integrity: sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.82': + resolution: {integrity: sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/dynamodb-codec@3.973.43': resolution: {integrity: sha512-5nw01fhFJEKM68n0R65S1DijiSAQWZVbvH7IxTIJpFFbggg816jBVYvhK7W+XISjvtdg0rMkF/gQmuLGH713mg==} engines: {node: '>=20.0.0'} @@ -697,6 +740,10 @@ packages: resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.50': + resolution: {integrity: sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.38': resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==} engines: {node: '>=20.0.0'} @@ -709,6 +756,10 @@ packages: resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.49': + resolution: {integrity: sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1080.0': resolution: {integrity: sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw==} engines: {node: '>=20.0.0'} @@ -733,6 +784,10 @@ packages: resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1155.0': + resolution: {integrity: sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.15': resolution: {integrity: sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==} engines: {node: '>=20.0.0'} @@ -753,6 +808,10 @@ packages: resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.975.0': + resolution: {integrity: sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-dynamodb@3.996.9': resolution: {integrity: sha512-16x2tRvl7OYpZ0W/DdFJieFriD13+RvuRBDbe5sj/tCEfK86HSGd7I2s5j0ivz8p6KWGkS+5wKRO9OliJkjUOQ==} engines: {node: '>=20.0.0'} @@ -1220,6 +1279,10 @@ packages: resolution: {integrity: sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==} engines: {node: '>=18.0.0'} + '@smithy/core@3.36.2': + resolution: {integrity: sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.14': resolution: {integrity: sha512-QgbuahIb2qxQeZQvNK0sw3aF3JH5zwH8j2lLp5DUasVXexGGMWULAR+7z0omPXFolCP/m5wN9M5lm9EGdSviTQ==} engines: {node: '>=18.0.0'} @@ -1300,6 +1363,10 @@ packages: resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.7.4': + resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.15.1': resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==} engines: {node: '>=18.0.0'} @@ -2798,10 +2865,10 @@ snapshots: '@aws-sdk/checksums@3.1000.13': dependencies: - '@aws-sdk/core': 3.978.0 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/client-api-gateway@3.1096.0': @@ -2894,6 +2961,17 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/client-cognito-identity-provider@3.1156.0': + dependencies: + '@aws-sdk/core': 3.978.5 + '@aws-sdk/credential-provider-node': 3.972.88 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/client-dynamodb@3.1112.0': dependencies: '@aws-sdk/core': 3.977.8 @@ -3249,6 +3327,17 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/core@3.978.5': + dependencies: + '@aws-sdk/types': 3.975.0 + '@aws-sdk/xml-builder': 3.972.41 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.36.2 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.54': dependencies: '@aws-sdk/core': 3.978.0 @@ -3297,6 +3386,14 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.76': + dependencies: + '@aws-sdk/core': 3.978.5 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.56': dependencies: '@aws-sdk/core': 3.978.0 @@ -3357,6 +3454,16 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.78': + dependencies: + '@aws-sdk/core': 3.978.5 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.61': dependencies: '@aws-sdk/core': 3.978.0 @@ -3437,6 +3544,22 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.973.21': + dependencies: + '@aws-sdk/core': 3.978.5 + '@aws-sdk/credential-provider-env': 3.972.76 + '@aws-sdk/credential-provider-http': 3.972.78 + '@aws-sdk/credential-provider-login': 3.972.83 + '@aws-sdk/credential-provider-process': 3.972.76 + '@aws-sdk/credential-provider-sso': 3.973.20 + '@aws-sdk/credential-provider-web-identity': 3.972.82 + '@aws-sdk/nested-clients': 3.997.50 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.973.7': dependencies: '@aws-sdk/core': 3.978.0 @@ -3507,6 +3630,15 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.83': + dependencies: + '@aws-sdk/core': 3.978.5 + '@aws-sdk/nested-clients': 3.997.50 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.63': dependencies: '@aws-sdk/credential-provider-env': 3.972.54 @@ -3605,6 +3737,20 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.88': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.76 + '@aws-sdk/credential-provider-http': 3.972.78 + '@aws-sdk/credential-provider-ini': 3.973.21 + '@aws-sdk/credential-provider-process': 3.972.76 + '@aws-sdk/credential-provider-sso': 3.973.20 + '@aws-sdk/credential-provider-web-identity': 3.972.82 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.54': dependencies: '@aws-sdk/core': 3.978.0 @@ -3653,6 +3799,14 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.76': + dependencies: + '@aws-sdk/core': 3.978.5 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.60': dependencies: '@aws-sdk/core': 3.978.0 @@ -3703,6 +3857,16 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.973.20': + dependencies: + '@aws-sdk/core': 3.978.5 + '@aws-sdk/nested-clients': 3.997.50 + '@aws-sdk/token-providers': 3.1155.0 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.973.6': dependencies: '@aws-sdk/core': 3.978.0 @@ -3767,6 +3931,15 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.82': + dependencies: + '@aws-sdk/core': 3.978.5 + '@aws-sdk/nested-clients': 3.997.50 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/dynamodb-codec@3.973.43': dependencies: '@aws-sdk/core': 3.978.0 @@ -3899,6 +4072,17 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.50': + dependencies: + '@aws-sdk/core': 3.978.5 + '@aws-sdk/signature-v4-multi-region': 3.996.49 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.38': dependencies: '@aws-sdk/types': 3.974.5 @@ -3920,6 +4104,13 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.49': + dependencies: + '@aws-sdk/types': 3.975.0 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1080.0': dependencies: '@aws-sdk/core': 3.978.1 @@ -3974,6 +4165,15 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/token-providers@3.1155.0': + dependencies: + '@aws-sdk/core': 3.978.5 + '@aws-sdk/nested-clients': 3.997.50 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/types@3.973.15': dependencies: '@smithy/types': 4.17.2 @@ -3999,6 +4199,11 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/types@3.975.0': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/util-dynamodb@3.996.9(@aws-sdk/client-dynamodb@3.1112.0)': dependencies: '@aws-sdk/client-dynamodb': 3.1112.0 @@ -4496,6 +4701,11 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@smithy/core@3.36.2': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.4.14': dependencies: '@smithy/core': 3.33.3 @@ -4615,6 +4825,12 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@smithy/signature-v4@5.7.4': + dependencies: + '@smithy/core': 3.36.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@smithy/types@4.15.1': dependencies: tslib: 2.8.1 From fe70e88bcc8e7a225718284d491667a71f416ac5 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Fri, 23 Oct 2026 12:12:40 -0400 Subject: [PATCH 2/3] feat(frontend): manage Cognito app clients and users Adds a users panel under the selected user pool: copyable app client ids, a create-user form that follows the pool's sign-in method, and the user list with delete. Maps the userpools icon key to the Cognito artwork. --- .../src/components/CognitoUsersPanel.test.tsx | 89 ++++++++++++++ .../src/components/CognitoUsersPanel.tsx | 114 ++++++++++++++++++ .../src/components/DynamicResourceView.tsx | 4 + .../frontend/src/components/serviceIcons.tsx | 4 + packages/frontend/src/index.css | 5 + 5 files changed, 216 insertions(+) create mode 100644 packages/frontend/src/components/CognitoUsersPanel.test.tsx create mode 100644 packages/frontend/src/components/CognitoUsersPanel.tsx diff --git a/packages/frontend/src/components/CognitoUsersPanel.test.tsx b/packages/frontend/src/components/CognitoUsersPanel.test.tsx new file mode 100644 index 0000000..d716492 --- /dev/null +++ b/packages/frontend/src/components/CognitoUsersPanel.test.tsx @@ -0,0 +1,89 @@ +import {QueryClient, QueryClientProvider} from '@tanstack/react-query' +import {render, screen} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {beforeEach, expect, test, vi} from 'vitest' +import {CognitoUsersPanel} from './CognitoUsersPanel' +import type {CloudResource} from '@/types/resource' + +const api = vi.hoisted(() => ({ + deleteFlatItem: vi.fn(), + getCloudResource: vi.fn(), + listFlatItems: vi.fn(), + putFlatItem: vi.fn(), +})) + +vi.mock('@/api/cloudProxyClient', () => api) +vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'})) + +const summary: CloudResource = { + id: 'us-east-1_8e65d229a', name: 'orders-users', cloud: 'aws', service: 'userpools', type: 'user-pool', + region: 'us-east-1', createdAt: null, metadata: {}, +} + +function pool(signInWith: 'email' | 'username'): CloudResource { + return {...summary, metadata: {signInWith, appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}]}} +} + +const alice = { + id: 'aa36ecc1-ed8a-43b5-84e9-988398c17436', collectionId: null, timestamp: '2026-10-23T16:01:22.000Z', metadata: {}, + body: {username: 'aa36ecc1-ed8a-43b5-84e9-988398c17436', email: 'alice@example.com', status: 'CONFIRMED', enabled: true}, +} + +function renderPanel() { + const client = new QueryClient({defaultOptions: {queries: {retry: false}}}) + return render() +} + +beforeEach(() => { + vi.clearAllMocks() + api.listFlatItems.mockResolvedValue({items: [alice], nextCursor: null}) + api.putFlatItem.mockResolvedValue(alice) + api.deleteFlatItem.mockResolvedValue(undefined) +}) + +test('shows the app client id and the pool users', async () => { + api.getCloudResource.mockResolvedValue(pool('email')) + renderPanel() + + expect(await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')).toBeInTheDocument() + expect(screen.getByRole('button', {name: 'Copy Client ID'})).toBeInTheDocument() + expect(await screen.findByText('alice@example.com')).toBeInTheDocument() + expect(screen.getByText('CONFIRMED')).toBeInTheDocument() + expect(api.getCloudResource).toHaveBeenCalledWith('aws', 'userpools', summary.id, expect.anything()) +}) + +test('creates an email user who can sign in immediately', async () => { + const user = userEvent.setup() + api.getCloudResource.mockResolvedValue(pool('email')) + renderPanel() + + await screen.findByText('a8e5c7d38d3b4a1a97c9d822de') + await user.type(screen.getByLabelText('Email'), 'bob@example.com') + await user.type(screen.getByLabelText('Password'), 'Passw0rd!') + await user.click(screen.getByRole('button', {name: 'Create user'})) + + expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {email: 'bob@example.com', password: 'Passw0rd!', permanent: true}) +}) + +test('creates a username user with a temporary password when asked', async () => { + const user = userEvent.setup() + api.getCloudResource.mockResolvedValue(pool('username')) + renderPanel() + + await user.type(await screen.findByLabelText('Username'), 'bob') + await user.type(screen.getByLabelText('Password'), 'Passw0rd!') + await user.click(screen.getByLabelText(/User can sign in now/)) + await user.click(screen.getByRole('button', {name: 'Create user'})) + + expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {username: 'bob', password: 'Passw0rd!', permanent: false}) +}) + +test('deletes a user by its stored username', async () => { + const user = userEvent.setup() + api.getCloudResource.mockResolvedValue(pool('email')) + renderPanel() + + await user.click(await screen.findByRole('button', {name: 'Delete user alice@example.com'})) + + expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, alice.id) +}) diff --git a/packages/frontend/src/components/CognitoUsersPanel.tsx b/packages/frontend/src/components/CognitoUsersPanel.tsx new file mode 100644 index 0000000..bb6fcb5 --- /dev/null +++ b/packages/frontend/src/components/CognitoUsersPanel.tsx @@ -0,0 +1,114 @@ +import {RefreshCw, Trash2} from 'lucide-react' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {useEffect, useState} from 'react' +import {deleteFlatItem, getCloudResource, listFlatItems, putFlatItem} from '@/api/cloudProxyClient' +import {CopyValueButton} from '@/components/CopyValueButton' +import {useAccountId} from '@/lib/accountStore' +import {formatDateTime} from '@/lib/format' +import type {CloudResource} from '@/types/resource' + +type Props = { + resource?: CloudResource + runtimeReachable: boolean +} + +type AppClient = {clientId: string; clientName: string} + +export function CognitoUsersPanel({resource, runtimeReachable}: Props) { + const accountId = useAccountId() + const queryClient = useQueryClient() + const [login, setLogin] = useState('') + const [password, setPassword] = useState('') + const [permanent, setPermanent] = useState(true) + const usersKey = ['cognito-users', accountId, resource?.id] + + // The table row is a list summary; sign-in method and app clients come from the full pool. + const poolQuery = useQuery({ + queryKey: ['cognito-pool', accountId, resource?.id], + queryFn: ({signal}) => getCloudResource('aws', 'userpools', resource?.id ?? '', signal), + enabled: Boolean(resource) && runtimeReachable, + }) + const usersQuery = useQuery({ + queryKey: usersKey, + queryFn: ({signal}) => listFlatItems('aws', 'userpools', resource?.id ?? '', undefined, signal), + enabled: Boolean(resource) && runtimeReachable, + }) + + useEffect(() => { + setLogin('') + setPassword('') + setPermanent(true) + }, [resource?.id]) + + const signInWithEmail = poolQuery.data?.metadata.signInWith !== 'username' + const createMutation = useMutation({ + mutationFn: () => putFlatItem('aws', 'userpools', resource?.id ?? '', { + ...(signInWithEmail ? {email: login.trim()} : {username: login.trim()}), + password, + permanent, + }), + onSuccess: () => { + setLogin('') + setPassword('') + return queryClient.invalidateQueries({queryKey: usersKey}) + }, + }) + const deleteMutation = useMutation({ + mutationFn: (username: string) => deleteFlatItem('aws', 'userpools', resource?.id ?? '', username), + onSuccess: () => queryClient.invalidateQueries({queryKey: usersKey}), + }) + + if (!resource) { + return

Select a user pool

Choose a user pool to manage its app clients and users.

+ } + + const users = usersQuery.data?.items ?? [] + const appClients = appClientsOf(poolQuery.data) + const pending = createMutation.isPending || deleteMutation.isPending + const error = poolQuery.error ?? usersQuery.error ?? createMutation.error ?? deleteMutation.error + const loginLabel = signInWithEmail ? 'Email' : 'Username' + + return
+
+

App clients

Use a client ID with the pool ID to sign users in from your app
+
+ {poolQuery.isSuccess && appClients.length === 0 &&

This pool has no app clients.

} + {appClients.length > 0 &&
{appClients.map((client) => ( + + ))}
NameClient ID
{client.clientName || '—'}{client.clientId}
} + +
+

Users

{users.length} users in {resource.name}
+ +
+
{ event.preventDefault(); if (login.trim() && password) createMutation.mutate() }}> + + + +
+
+ {error instanceof Error &&

{error.message}

} + {usersQuery.isLoading &&
Loading users
} + {usersQuery.isSuccess && users.length === 0 &&

No users

Create a user to sign in to apps that use this pool.

} + {users.length > 0 &&
{users.map((user) => { + const name = stringValue(user.body.email) || stringValue(user.body.username) + return + + + + + + + })}
{loginLabel}StatusEnabledCreated
{name}{stringValue(user.body.status) || '—'}{user.body.enabled === false ? 'No' : 'Yes'}{formatDateTime(user.timestamp) ?? '—'}
} +
+} + +function appClientsOf(pool?: CloudResource): AppClient[] { + const clients = pool?.metadata.appClients + if (!Array.isArray(clients)) return [] + return clients.filter((client): client is AppClient => typeof client?.clientId === 'string' && client.clientId !== '') +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value : '' +} diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index 880d43c..c8c201d 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -63,6 +63,7 @@ import { EcrImageBrowser } from "@/components/EcrImageBrowser"; import { LoadBalancerPanel } from "@/components/LoadBalancerPanel"; import { TargetGroupPanel } from "@/components/TargetGroupPanel"; import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel"; +import { CognitoUsersPanel } from "@/components/CognitoUsersPanel"; import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger"; import { CodeBuildPanel } from "@/components/CodeBuildPanel"; import { CodePipelinePanel } from "@/components/CodePipelinePanel"; @@ -969,6 +970,9 @@ export function DynamicResourceView({ {service === "loadbalancing" && cloud === "aws" && ( )} + {service === "userpools" && cloud === "aws" && ( + + )} {service === "compute" && ( = { logs: ScrollText, iam: ShieldCheck, kms: Lock, + userpools: UsersRound, loadbalancing: Scale, parameters: SlidersHorizontal, configuration: SlidersHorizontal, @@ -127,6 +130,7 @@ const AWS_SERVICE_ICON_ASSETS: Record = { secrets: secretsManagerIcon, serverless: lambdaIcon, storage: s3Icon, + userpools: cognitoIcon, workflows: stepFunctionsIcon, } diff --git a/packages/frontend/src/index.css b/packages/frontend/src/index.css index db17acb..71fe441 100644 --- a/packages/frontend/src/index.css +++ b/packages/frontend/src/index.css @@ -4261,6 +4261,11 @@ pre, gap: 14px; } +.cognito-table { + overflow-x: auto; + border-bottom: 1px solid var(--border); +} + .diagnostics { display: grid; gap: 14px; From f1574b89df48d32dbda55368e59ec43538ae08a4 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Fri, 23 Oct 2026 12:12:40 -0400 Subject: [PATCH 3/3] docs: regenerate the service matrix Adds Cognito and the services the table had drifted behind on (ECS, Fargate, Lightsail, ElastiCache, MSK, Kinesis and others). --- README.md | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index dbfb555..a41e4a2 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,21 @@ cd packages/api && bun run scripts/service-matrix.ts |---|---|---|---|---| | Compute | Compute | Yes (list, inspect, create, delete) | Yes (list, inspect, create, delete) | No | | Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) | -| Compute | Serverless | Yes (list, create, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) | +| Compute | Serverless | Yes (list, create, update, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) | | Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) | -| Compute | CodeBuild | Yes (list, create, delete, inspect) | No | No | +| Compute | ECS | Yes (list, create, delete, inspect) | No | No | +| Compute | Fargate | Yes (list, create, delete, inspect) | No | No | +| Compute | Lightsail | Yes (list, create, inspect, update, delete) | No | No | +| Developer Tools | CodeBuild | Yes (list, create, delete, inspect) | No | No | +| Developer Tools | CodePipeline | Yes (list, create, delete, inspect) | No | No | +| Developer Tools | CodeDeploy | Yes (list, create, delete, inspect) | No | No | | Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | +| Storage | ECR / Elastic Container Registry | Yes (list, create, delete, inspect) | No | No | | Databases | Database | Yes (list, create, update, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No | +| Databases | ElastiCache | Yes (list, create, delete, inspect) | No | No | | Networking | Networking | Yes (list) | No | No | -| Networking | ELB / Load Balancing | Yes (list, create, delete, inspect) | No | No | +| Networking | ELB / Load Balancing | Yes (list, create, update, delete, inspect) | No | No | | Networking | ELB Target Groups / Target Groups | Yes (list, create, delete, inspect) | No | No | | Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Integration | API Gateway | Yes (list, create, delete, inspect) | No | No | @@ -77,11 +84,14 @@ cd packages/api && bun run scripts/service-matrix.ts | Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No | | Integration | SES Mailbox / Email | Yes (list, inspect) | No | No | | Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) | -| Integration | CodePipeline | Yes (list, create, delete, inspect) | No | No | -| Integration | Step Functions / Workflows | Yes (list, create, delete, inspect) | No | No | -| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, delete, inspect) | No | No | +| Integration | Step Functions / Workflows | Yes (list, create, update, delete, inspect) | No | No | +| Analytics | MSK / Managed Streaming for Kafka | Yes (legacy page) | No | No | +| Analytics | MSK Connect / Managed Streaming for Kafka Connect | Yes (legacy page) | No | No | +| Analytics | Kinesis / Kinesis Data Streams | Yes (list, create, inspect, delete) | No | No | +| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, update, delete, inspect) | No | No | | Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No | | Security | Identity | Yes (list, create, delete, inspect) | No | No | +| Security | Cognito / User Directories | Yes (list, create, delete, inspect) | No | No | | Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No | | Security | Parameter Store | Yes (list, create, delete, inspect) | No | No |