From b4c2cadf5c391dd5a93df4abb11ddbb0fdeac004 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Fri, 23 Oct 2026 17:37:35 -0400 Subject: [PATCH 1/3] feat(api): Cognito user actions, groups and test sign-in Adds enable, disable, confirm, sign-out, password reset and set-password for one user; user group membership; group list, create and delete; and a test sign-in that returns decoded token claims. A refused test sign-in is an outcome rather than a 401, which would end the console session. Also adds a generic GET for one flat item and a schema deleteImpact so delete confirmations can say what else goes with a resource. --- .../adapter-aws/AwsUserPoolsAdapter.test.ts | 121 ++++++++++- .../src/adapter-aws/AwsUserPoolsAdapter.ts | 197 +++++++++++++++++- .../api/src/adapter-aws/awsErrors.test.ts | 1 + packages/api/src/adapter-aws/awsErrors.ts | 1 + packages/api/src/cloud-spi/types.ts | 53 +++++ packages/api/src/cloud-spi/userPoolsSchema.ts | 14 ++ packages/api/src/cloudProxy.test.ts | 21 +- packages/api/src/routes/clouds.test.ts | 53 +++++ packages/api/src/routes/clouds.ts | 65 ++++++ packages/api/src/service/CloudProxyService.ts | 47 +++++ 10 files changed, 569 insertions(+), 4 deletions(-) diff --git a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts index 31df243..937624a 100644 --- a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts @@ -1,5 +1,17 @@ +import {createHmac} from 'node:crypto' import {describe, expect, test} from 'bun:test' import { + AdminAddUserToGroupCommand, + AdminConfirmSignUpCommand, + AdminDisableUserCommand, + AdminListGroupsForUserCommand, + AdminRemoveUserFromGroupCommand, + AdminUserGlobalSignOutCommand, + CreateGroupCommand, + DeleteGroupCommand, + DescribeUserPoolClientCommand, + InitiateAuthCommand, + ListGroupsCommand, AdminCreateUserCommand, AdminDeleteUserCommand, AdminGetUserCommand, @@ -14,7 +26,7 @@ import { type CognitoIdentityProviderClient, } from '@aws-sdk/client-cognito-identity-provider' import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter' -import {ValidationError} from '../cloud-spi/errors' +import {ConflictError, ValidationError} from '../cloud-spi/errors' type SendResult = Record @@ -255,3 +267,110 @@ describe('AwsUserPoolsAdapter users', () => { expect((sent[0] as AdminDeleteUserCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username}) }) }) + +function jwt(claims: Record): string { + return ['{"alg":"RS256"}', JSON.stringify(claims), 'sig'].map((part) => Buffer.from(part).toString('base64url')).join('.') +} + +describe('AwsUserPoolsAdapter user actions and groups', () => { + test('reads a user with the groups it belongs to', async () => { + const {client, sent} = stubCognito((command) => { + if (command instanceof AdminGetUserCommand) return {Username: alice.Username, UserAttributes: alice.Attributes, UserStatus: 'CONFIRMED', Enabled: true, UserCreateDate: CREATED} + if (command instanceof AdminListGroupsForUserCommand) return {Groups: [{GroupName: 'admins'}]} + return {} + }) + const user = await new AwsUserPoolsAdapter(client).items.getItem!(POOL_ID, alice.Username) + + expect(user?.body.groups).toEqual(['admins']) + expect((sent[1] as AdminListGroupsForUserCommand).input).toMatchObject({UserPoolId: POOL_ID, Username: alice.Username}) + }) + + test('runs each user action as its Admin call', async () => { + const {client, sent} = stubCognito(() => ({})) + const adapter = new AwsUserPoolsAdapter(client) + + await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'disable'}) + await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'confirm'}) + await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'signOut'}) + await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'setPassword', password: ' Passw0rd! '}) + + expect(sent[0]).toBeInstanceOf(AdminDisableUserCommand) + expect(sent[1]).toBeInstanceOf(AdminConfirmSignUpCommand) + expect(sent[2]).toBeInstanceOf(AdminUserGlobalSignOutCommand) + expect((sent[3] as AdminSetUserPasswordCommand).input).toEqual({UserPoolId: POOL_ID, Username: 'bob', Password: ' Passw0rd! ', Permanent: true}) + }) + + test('rejects unknown actions and reports a refused one as a conflict, never a 401', async () => { + const {client} = stubCognito(() => { throw awsError('NotAuthorizedException') }) + const adapter = new AwsUserPoolsAdapter(client) + + await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'explode'} as never)).rejects.toBeInstanceOf(ValidationError) + await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'setPassword'} as never)).rejects.toBeInstanceOf(ValidationError) + await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'confirm'})).rejects.toBeInstanceOf(ConflictError) + }) + + test('sets a user\'s groups by adding and removing only the difference', async () => { + const {client, sent} = stubCognito((command) => command instanceof AdminListGroupsForUserCommand ? {Groups: [{GroupName: 'staff'}, {GroupName: 'beta'}]} : {}) + await new AwsUserPoolsAdapter(client).setUserPoolUserGroups(POOL_ID, 'bob', ['staff', 'admins']) + + const changes = sent.slice(1) + expect(changes).toHaveLength(2) + expect(changes[0]).toBeInstanceOf(AdminAddUserToGroupCommand) + expect((changes[0] as AdminAddUserToGroupCommand).input.GroupName).toBe('admins') + expect(changes[1]).toBeInstanceOf(AdminRemoveUserFromGroupCommand) + expect((changes[1] as AdminRemoveUserFromGroupCommand).input.GroupName).toBe('beta') + }) + + test('lists, creates and deletes groups', async () => { + const {client, sent} = stubCognito((command) => { + if (command instanceof ListGroupsCommand) return {Groups: [{GroupName: 'admins', Description: 'Full access', Precedence: 0, CreationDate: CREATED}]} + if (command instanceof CreateGroupCommand) return {Group: {GroupName: command.input.GroupName, Precedence: command.input.Precedence, CreationDate: CREATED}} + return {} + }) + const adapter = new AwsUserPoolsAdapter(client) + + expect(await adapter.listUserPoolGroups(POOL_ID)).toEqual([{name: 'admins', description: 'Full access', precedence: 0, createdAt: CREATED.toISOString()}]) + expect(await adapter.createUserPoolGroup(POOL_ID, {name: 'editors', precedence: 5})).toMatchObject({name: 'editors', precedence: 5}) + await adapter.deleteUserPoolGroup(POOL_ID, 'editors') + + expect((sent[1] as CreateGroupCommand).input).toEqual({UserPoolId: POOL_ID, GroupName: 'editors', Precedence: 5}) + expect(sent[2]).toBeInstanceOf(DeleteGroupCommand) + }) + + test('validates a group before creating it', async () => { + const {client, sent} = stubCognito(() => ({})) + const adapter = new AwsUserPoolsAdapter(client) + + await expect(adapter.createUserPoolGroup(POOL_ID, {name: 'has space'})).rejects.toBeInstanceOf(ValidationError) + await expect(adapter.createUserPoolGroup(POOL_ID, {name: 'ok', precedence: -1})).rejects.toBeInstanceOf(ValidationError) + expect(sent).toHaveLength(0) + }) + + test('test sign-in returns decoded claims, adding SECRET_HASH for a client with a secret', async () => { + const {client, sent} = stubCognito((command) => { + if (command instanceof DescribeUserPoolClientCommand) return {UserPoolClient: {ClientId: 'client-1', ClientSecret: 'shh'}} + if (command instanceof InitiateAuthCommand) return {AuthenticationResult: {ExpiresIn: 3600, IdToken: jwt({sub: 's-1', email: 'alice@example.com', token_use: 'id'}), AccessToken: jwt({sub: 's-1', token_use: 'access'})}} + return {} + }) + const result = await new AwsUserPoolsAdapter(client).testUserPoolSignIn(POOL_ID, {clientId: 'client-1', username: 'alice@example.com', password: 'Passw0rd!'}) + + expect(result).toEqual({outcome: 'signed-in', expiresIn: 3600, idToken: {sub: 's-1', email: 'alice@example.com', token_use: 'id'}, accessToken: {sub: 's-1', token_use: 'access'}}) + const auth = (sent[1] as InitiateAuthCommand).input + expect(auth.AuthFlow).toBe('USER_PASSWORD_AUTH') + expect(auth.AuthParameters?.SECRET_HASH).toBe(createHmac('sha256', 'shh').update('alice@example.comclient-1').digest('base64')) + }) + + test('test sign-in reports a wrong password or a challenge as its outcome', async () => { + const refusing = stubCognito((command) => { + if (command instanceof InitiateAuthCommand) throw Object.assign(awsError('NotAuthorizedException'), {message: 'Incorrect username or password.'}) + return {UserPoolClient: {ClientId: 'client-1'}} + }) + expect(await new AwsUserPoolsAdapter(refusing.client).testUserPoolSignIn(POOL_ID, {clientId: 'client-1', username: 'a', password: 'x'})) + .toEqual({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'}) + expect((refusing.sent[1] as InitiateAuthCommand).input.AuthParameters?.SECRET_HASH).toBeUndefined() + + const challenging = stubCognito((command) => command instanceof InitiateAuthCommand ? {ChallengeName: 'NEW_PASSWORD_REQUIRED'} : {UserPoolClient: {}}) + expect(await new AwsUserPoolsAdapter(challenging.client).testUserPoolSignIn(POOL_ID, {clientId: 'c', username: 'a', password: 'x'})) + .toEqual({outcome: 'challenge', challengeName: 'NEW_PASSWORD_REQUIRED'}) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts index e5e5b93..4e69695 100644 --- a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts +++ b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts @@ -1,17 +1,32 @@ +import {createHmac} from 'node:crypto' import { + AdminAddUserToGroupCommand, + AdminConfirmSignUpCommand, AdminCreateUserCommand, AdminDeleteUserCommand, + AdminDisableUserCommand, + AdminEnableUserCommand, AdminGetUserCommand, + AdminListGroupsForUserCommand, + AdminRemoveUserFromGroupCommand, + AdminResetUserPasswordCommand, AdminSetUserPasswordCommand, + AdminUserGlobalSignOutCommand, + CreateGroupCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, + DeleteGroupCommand, DeleteUserPoolCommand, + DescribeUserPoolClientCommand, DescribeUserPoolCommand, + InitiateAuthCommand, + ListGroupsCommand, ListUserPoolClientsCommand, ListUserPoolsCommand, ListUsersCommand, type AttributeType, type CognitoIdentityProviderClient, + type GroupType, type PasswordPolicyType, type UserPoolClientDescription, type UserPoolDescriptionType, @@ -19,8 +34,9 @@ import { 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 {ConflictError, RuntimeError, ValidationError} from '../cloud-spi/errors' import { + USER_POOL_GROUP_NAME_PATTERN, USER_POOL_NAME_PATTERN, USER_POOL_PASSWORD_POLICIES, USER_POOL_SIGN_IN, @@ -30,8 +46,13 @@ import type { CloudResource, CloudServiceAdapter, CreateResourceInput, + CreateUserPoolGroupInput, ResourceQuery, ServiceSchema, + UserPoolGroup, + UserPoolSignInTestInput, + UserPoolSignInTestResult, + UserPoolUserAction, } from '../cloud-spi/types' /** Cognito caps every list page at 60. */ @@ -51,6 +72,9 @@ 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. + * - 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. * * 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. @@ -140,6 +164,126 @@ export class AwsUserPoolsAdapter implements CloudServiceAdapter { await this.cognito.send(new DeleteUserPoolCommand({UserPoolId: id})) } + async updateUserPoolUser(poolId: string, username: string, input: UserPoolUserAction): Promise { + const target = {UserPoolId: poolId, Username: username} + const action = (input as {action?: unknown} | null)?.action + const command = (() => { + switch (action) { + case 'enable': return new AdminEnableUserCommand(target) + case 'disable': return new AdminDisableUserCommand(target) + case 'confirm': return new AdminConfirmSignUpCommand(target) + case 'resetPassword': return new AdminResetUserPasswordCommand(target) + case 'signOut': return new AdminUserGlobalSignOutCommand(target) + case 'setPassword': { + const {password, permanent} = input as {password?: unknown; permanent?: unknown} + if (typeof password !== 'string' || !password) throw new ValidationError('password is required') + if (permanent !== undefined && typeof permanent !== 'boolean') throw new ValidationError('permanent must be true or false') + return new AdminSetUserPasswordCommand({...target, Password: password, Permanent: permanent ?? true}) + } + default: + throw new ValidationError('action must be one of enable, disable, confirm, resetPassword, signOut, setPassword') + } + })() + try { + await this.cognito.send(command as AdminEnableUserCommand) + } catch (error) { + throw asConflict(error) + } + } + + /** Makes the user's groups exactly `groups`, adding and removing only the difference. */ + async setUserPoolUserGroups(poolId: string, username: string, groups: string[]): Promise { + if (!Array.isArray(groups) || groups.some((group) => typeof group !== 'string' || !group)) { + throw new ValidationError('groups must be a list of group names') + } + const wanted = new Set(groups) + const current = new Set(await this.listGroupsForUser(poolId, username)) + for (const group of wanted) { + if (!current.has(group)) await this.cognito.send(new AdminAddUserToGroupCommand({UserPoolId: poolId, Username: username, GroupName: group})) + } + for (const group of current) { + if (!wanted.has(group)) await this.cognito.send(new AdminRemoveUserFromGroupCommand({UserPoolId: poolId, Username: username, GroupName: group})) + } + } + + async listUserPoolGroups(poolId: string): Promise { + const groups: GroupType[] = [] + let nextToken: string | undefined + do { + const res = await this.cognito.send(new ListGroupsCommand({UserPoolId: poolId, Limit: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})})) + groups.push(...(res.Groups ?? [])) + nextToken = res.NextToken || undefined + } while (nextToken) + return groups.flatMap(toGroup) + } + + async createUserPoolGroup(poolId: string, input: CreateUserPoolGroupInput): Promise { + const name = requiredString(input?.name, 'name') + if (!new RegExp(USER_POOL_GROUP_NAME_PATTERN, 'u').test(name)) throw new ValidationError('name must be up to 128 characters with no spaces') + const description = optionalString(input.description, 'description') + if (description && description.length > 2048) throw new ValidationError('description must be 2048 characters or fewer') + const precedence = input.precedence + if (precedence !== undefined && precedence !== null && (!Number.isInteger(precedence) || precedence < 0)) { + throw new ValidationError('precedence must be a whole number of 0 or more') + } + + const res = await this.cognito.send(new CreateGroupCommand({ + UserPoolId: poolId, + GroupName: name, + ...(description ? {Description: description} : {}), + ...(typeof precedence === 'number' ? {Precedence: precedence} : {}), + })) + const group = res.Group ? toGroup(res.Group)[0] : undefined + if (!group) throw new RuntimeError(`Cognito did not return the group ${name} created in ${poolId}`) + return group + } + + async deleteUserPoolGroup(poolId: string, groupName: string): Promise { + await this.cognito.send(new DeleteGroupCommand({UserPoolId: poolId, GroupName: groupName})) + } + + /** + * Signs in the way an app would (`InitiateAuth`, `USER_PASSWORD_AUTH`) and + * returns the decoded claims. A refused sign-in is a result, not an error: + * the wrong password is exactly what this is for trying. + */ + async testUserPoolSignIn(poolId: string, input: UserPoolSignInTestInput): Promise { + const clientId = requiredString(input?.clientId, 'clientId') + const username = requiredString(input.username, 'username') + if (typeof input.password !== 'string' || !input.password) throw new ValidationError('password is required') + + const client = await this.cognito.send(new DescribeUserPoolClientCommand({UserPoolId: poolId, ClientId: clientId})) + const secret = client.UserPoolClient?.ClientSecret + try { + const res = await this.cognito.send(new InitiateAuthCommand({ + ClientId: clientId, + AuthFlow: 'USER_PASSWORD_AUTH', + AuthParameters: { + USERNAME: username, + PASSWORD: input.password, + ...(secret ? {SECRET_HASH: secretHash(username, clientId, secret)} : {}), + }, + })) + if (res.ChallengeName) return {outcome: 'challenge', challengeName: res.ChallengeName} + const result = res.AuthenticationResult + if (!result?.IdToken || !result.AccessToken) throw new RuntimeError('Cognito returned no tokens') + return { + outcome: 'signed-in', + expiresIn: result.ExpiresIn ?? null, + idToken: decodeJwtPayload(result.IdToken), + accessToken: decodeJwtPayload(result.AccessToken), + } + } catch (error) { + const name = (error as {name?: string} | null)?.name ?? '' + if (SIGN_IN_REFUSALS.has(name)) return {outcome: 'rejected', code: name, message: (error as Error).message} + throw error + } + } + + private async listGroupsForUser(poolId: string, username: string): Promise { + return listGroupNamesForUser(this.cognito, poolId, username) + } + private async listClients(poolId: string): Promise { const clients: UserPoolClientDescription[] = [] let nextToken: string | undefined @@ -169,7 +313,9 @@ class UserStore implements ItemStoreAdapter { 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 + const item = toUserItem(user.Username, user.UserAttributes, user)[0] + if (!item) return null + return {...item, body: {...item.body, groups: await listGroupNamesForUser(this.cognito, poolId, item.id)}} } catch (error) { if (isMissing(error)) return null throw error @@ -215,6 +361,53 @@ class UserStore implements ItemStoreAdapter { } } +/** Refusals a test sign-in reports as its outcome rather than failing. */ +const SIGN_IN_REFUSALS = new Set([ + 'NotAuthorizedException', + 'UserNotFoundException', + 'UserNotConfirmedException', + 'PasswordResetRequiredException', + 'InvalidParameterException', +]) + +async function listGroupNamesForUser(cognito: CognitoIdentityProviderClient, poolId: string, username: string): Promise { + const names: string[] = [] + let nextToken: string | undefined + do { + const res = await cognito.send(new AdminListGroupsForUserCommand({UserPoolId: poolId, Username: username, Limit: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})})) + names.push(...(res.Groups ?? []).flatMap((group) => group.GroupName ? [group.GroupName] : [])) + nextToken = res.NextToken || undefined + } while (nextToken) + return names +} + +function toGroup(group: GroupType): UserPoolGroup[] { + if (!group.GroupName) return [] + return [{ + name: group.GroupName, + description: group.Description ?? null, + precedence: group.Precedence ?? null, + createdAt: group.CreationDate ? group.CreationDate.toISOString() : null, + }] +} + +/** Required by app clients that have a secret: Base64(HMAC-SHA256(secret, username + clientId)). */ +function secretHash(username: string, clientId: string, secret: string): string { + return createHmac('sha256', secret).update(username + clientId).digest('base64') +} + +/** The token came straight from Cognito, so only its payload is read; nothing is trusted from it. */ +function decodeJwtPayload(token: string): Record { + const payload = token.split('.')[1] + if (!payload) throw new RuntimeError('Cognito returned a malformed token') + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Record +} + +function asConflict(error: unknown): unknown { + if ((error as {name?: string} | null)?.name !== 'NotAuthorizedException') return error + return new ConflictError((error as Error).message || 'The user is not in a state that allows this') +} + function toSummaryResource(pool: UserPoolDescriptionType): CloudResource[] { if (!pool.Id) return [] return [{ diff --git a/packages/api/src/adapter-aws/awsErrors.test.ts b/packages/api/src/adapter-aws/awsErrors.test.ts index dcceb0e..753ceaa 100644 --- a/packages/api/src/adapter-aws/awsErrors.test.ts +++ b/packages/api/src/adapter-aws/awsErrors.test.ts @@ -137,6 +137,7 @@ describe('cognito errors carry HTTP 400 and keep their meaning', () => { ['UserNotFoundException', 404], ['UsernameExistsException', 409], ['AliasExistsException', 409], + ['GroupExistsException', 409], ['InvalidPasswordException', 400], ] diff --git a/packages/api/src/adapter-aws/awsErrors.ts b/packages/api/src/adapter-aws/awsErrors.ts index 1776061..d0d5339 100644 --- a/packages/api/src/adapter-aws/awsErrors.ts +++ b/packages/api/src/adapter-aws/awsErrors.ts @@ -60,6 +60,7 @@ const CONFLICT_NAMES = new Set([ 'DeleteConflictException', 'UsernameExistsException', 'AliasExistsException', + 'GroupExistsException', ]) const ACCESS_DENIED_NAMES = new Set([ diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 42e94fb..b5c6bfd 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -171,6 +171,13 @@ export type KubernetesActionName = | 'listFargateProfiles' | 'createFargateProfile' | 'deleteFargateProfile' +export type UserPoolActionName = + | 'updateUser' + | 'setUserGroups' + | 'listGroups' + | 'createGroup' + | 'deleteGroup' + | 'testSignIn' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' export interface CapabilitySchema { @@ -214,6 +221,7 @@ export interface ServiceSchema { objectActions?: CapabilitySchema[] databaseActions?: CapabilitySchema[] kubernetesActions?: CapabilitySchema[] + userPoolActions?: CapabilitySchema[] // Child levels advertise separately from the resource level because a // store can be readable at the leaf and writable at the collection — // CloudWatch Logs creates streams but cannot delete an event. @@ -223,6 +231,11 @@ export interface ServiceSchema { filters: FieldSchema[] columns: TableColumnSchema[] updateFields?: FieldSchema[] + /** + * Counts read from the resource (`path` as in columns) that the delete + * confirmation names, so deleting a pool says it also deletes its users. + */ + deleteImpact?: {path: string; singular: string; plural: string}[] } export type KnownResourceType = @@ -411,6 +424,40 @@ export interface SqsMoveTaskResult { taskHandle: string } +/** What an admin can do to one user pool user, each a single Admin* call. */ +export type UserPoolUserAction = + | {action: 'enable'} + | {action: 'disable'} + | {action: 'confirm'} + | {action: 'resetPassword'} + | {action: 'signOut'} + | {action: 'setPassword'; password: string; permanent?: boolean} + +export interface UserPoolGroup { + name: string + description: string | null + precedence: number | null + createdAt: string | null +} + +export interface CreateUserPoolGroupInput { + name: string + description?: string + precedence?: number +} + +export interface UserPoolSignInTestInput { + clientId: string + username: string + password: string +} + +/** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */ +export type UserPoolSignInTestResult = + | {outcome: 'signed-in'; expiresIn: number | null; idToken: Record; accessToken: Record} + | {outcome: 'challenge'; challengeName: string} + | {outcome: 'rejected'; code: string; message: string} + /** Provider-neutral representation of a managed Kubernetes node pool. */ export interface KubernetesNodegroup { id: string @@ -917,6 +964,12 @@ export interface CloudServiceAdapter { listKubernetesFargateProfiles?(clusterId: string): Promise createKubernetesFargateProfile?(clusterId: string, input: CreateKubernetesFargateProfileInput): Promise deleteKubernetesFargateProfile?(clusterId: string, profileId: string): Promise + updateUserPoolUser?(poolId: string, username: string, input: UserPoolUserAction): Promise + setUserPoolUserGroups?(poolId: string, username: string, groups: string[]): Promise + listUserPoolGroups?(poolId: string): Promise + createUserPoolGroup?(poolId: string, input: CreateUserPoolGroupInput): Promise + deleteUserPoolGroup?(poolId: string, groupName: string): Promise + testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise /** Clears the provider's locally captured email inbox, if it exposes one. */ clearEmailInbox?(): Promise // AppConfig nested resources, keyed by the ids the provider needs. Inspect diff --git a/packages/api/src/cloud-spi/userPoolsSchema.ts b/packages/api/src/cloud-spi/userPoolsSchema.ts index 097dfe9..ae19a69 100644 --- a/packages/api/src/cloud-spi/userPoolsSchema.ts +++ b/packages/api/src/cloud-spi/userPoolsSchema.ts @@ -4,6 +4,8 @@ import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from 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 +/** Cognito's group name rule: 1–128 letters, marks, symbols, digits or punctuation (no whitespace). Needs the `u` flag. */ +export const USER_POOL_GROUP_NAME_PATTERN = '^[\\p{L}\\p{M}\\p{S}\\p{N}\\p{P}]{1,128}$' const userPoolColumns: TableColumnSchema[] = [ {name: 'name', label: 'Name'}, @@ -72,9 +74,21 @@ export function awsUserPoolsSchema(): ServiceSchema { {name: 'put', label: 'Create user', enabled: true, status: 'available', runtimeRequired: true}, {name: 'delete', label: 'Delete user', enabled: true, status: 'available', runtimeRequired: true}, ], + userPoolActions: [ + {name: 'updateUser', label: 'Enable, disable, confirm, sign out or reset a user', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'setUserGroups', label: 'Change a user\'s groups', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'listGroups', label: 'List groups', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'createGroup', label: 'Create group', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'deleteGroup', label: 'Delete group', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'testSignIn', label: 'Test sign-in', enabled: true, status: 'available', runtimeRequired: true}, + ], }, filters: userPoolFilters, columns: userPoolColumns, + deleteImpact: [ + {path: 'metadata.estimatedNumberOfUsers', singular: 'user', plural: 'users'}, + {path: 'metadata.appClients', singular: 'app client', plural: 'app clients'}, + ], } } diff --git a/packages/api/src/cloudProxy.test.ts b/packages/api/src/cloudProxy.test.ts index 5b508f4..2ab4a44 100644 --- a/packages/api/src/cloudProxy.test.ts +++ b/packages/api/src/cloudProxy.test.ts @@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test' import {createCloudAdapterRegistry} from './cloudProxy' import {checkChildCapabilities} from './cloud-spi/childCapabilities' import {isServiceType} from './cloud-spi/serviceCatalog' -import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, KubernetesActionName, ResourceActionName} from './cloud-spi/types' +import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, KubernetesActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types' /** * Guards the schema-to-implementation contract for every registered adapter. @@ -47,6 +47,15 @@ const METHOD_FOR_KUBERNETES_ACTION: Record = { + updateUser: 'updateUserPoolUser', + setUserGroups: 'setUserPoolUserGroups', + listGroups: 'listUserPoolGroups', + createGroup: 'createUserPoolGroup', + deleteGroup: 'deleteUserPoolGroup', + testSignIn: 'testUserPoolSignIn', +} + const registry = createCloudAdapterRegistry() function registeredAdapters(): Array<{cloud: CloudProvider; adapter: CloudServiceAdapter}> { @@ -121,6 +130,15 @@ describe('registered adapters honour their schema', () => { `${label} advertises ${capability.name} as available but has no ${String(method)}()`, ).toBe('function') } + + const userPoolClaims = (adapter.schema().capabilities?.userPoolActions ?? []).filter((capability) => capability.status === 'available') + for (const capability of userPoolClaims) { + const method = METHOD_FOR_USER_POOL_ACTION[capability.name] + expect( + typeof adapter[method], + `${label} advertises ${capability.name} as available but has no ${String(method)}()`, + ).toBe('function') + } }) test(`${label} child collections match their advertised capabilities`, () => { @@ -133,6 +151,7 @@ describe('registered adapters honour their schema', () => { ...(adapter.schema().capabilities?.objectActions ?? []), ...(adapter.schema().capabilities?.databaseActions ?? []), ...(adapter.schema().capabilities?.kubernetesActions ?? []), + ...(adapter.schema().capabilities?.userPoolActions ?? []), ...(adapter.schema().capabilities?.collectionActions ?? []), ...(adapter.schema().capabilities?.itemActions ?? []), ] diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index d98d7a7..5ee3054 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -16,6 +16,7 @@ import {awsEksSchema} from '../cloud-spi/eksSchema' import {awsKmsSchema} from '../cloud-spi/kmsSchema' import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema' import {awsSesEmailSchema} from '../cloud-spi/emailSchema' +import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema' import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema' import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema' import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema' @@ -694,6 +695,58 @@ describe('cloud schema routes', () => { ]) }) + test('reads one flat item, answering 404 when it is gone', async () => { + const app = appWithRoutes([mockAdapter('aws', { + service: 'userpools', + schema: awsUserPoolsSchema, + items: { + listItems: async () => ({items: [], nextCursor: null}), + getItem: async (_poolId, username) => username === 'bob@example.com' + ? {id: 'u-1', collectionId: null, timestamp: null, body: {email: username, groups: ['admins']}, metadata: {}} + : null, + }, + })]) + + const found = await app.request('/api/clouds/aws/services/userpools/resources/us-east-1_x/items/bob%40example.com') + const missing = await app.request('/api/clouds/aws/services/userpools/resources/us-east-1_x/items/nobody') + + expect(found.status).toBe(200) + expect((await found.json()).body.groups).toEqual(['admins']) + expect(missing.status).toBe(404) + }) + + test('routes Cognito user actions, groups and test sign-in to the adapter', async () => { + const calls: unknown[] = [] + const app = appWithRoutes([mockAdapter('aws', { + service: 'userpools', + schema: awsUserPoolsSchema, + updateUserPoolUser: async (poolId, username, input) => { calls.push(['action', poolId, username, input]) }, + setUserPoolUserGroups: async (poolId, username, groups) => { calls.push(['groups', poolId, username, groups]) }, + listUserPoolGroups: async () => [{name: 'admins', description: null, precedence: 0, createdAt: null}], + createUserPoolGroup: async (_poolId, input) => ({name: input.name, description: null, precedence: null, createdAt: null}), + deleteUserPoolGroup: async (poolId, groupName) => { calls.push(['deleteGroup', poolId, groupName]) }, + testUserPoolSignIn: async () => ({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'}), + })]) + const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)}) + const base = '/api/clouds/aws/services/userpools/resources/us-east-1_x' + + expect((await app.request(`${base}/users/bob%40example.com/actions`, json('POST', {action: 'disable'}))).status).toBe(200) + expect((await app.request(`${base}/users/bob%40example.com/groups`, json('PUT', {groups: ['admins']}))).status).toBe(200) + expect(await (await app.request(`${base}/groups`)).json()).toEqual([{name: 'admins', description: null, precedence: 0, createdAt: null}]) + expect((await app.request(`${base}/groups`, json('POST', {name: 'editors'}))).status).toBe(201) + expect((await app.request(`${base}/groups/editors`, {method: 'DELETE'})).status).toBe(200) + + const signIn = await app.request(`${base}/sign-in-test`, json('POST', {clientId: 'c', username: 'bob', password: 'x'})) + expect(signIn.status).toBe(200) + expect((await signIn.json()).outcome).toBe('rejected') + + expect(calls).toEqual([ + ['action', 'us-east-1_x', 'bob@example.com', {action: 'disable'}], + ['groups', 'us-east-1_x', 'bob@example.com', ['admins']], + ['deleteGroup', 'us-east-1_x', 'editors'], + ]) + }) + test('clears the SES mailbox through the email adapter', async () => { let cleared = false const app = appWithRoutes([mockAdapter('aws', { diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 06f39de..3031510 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -392,6 +392,61 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) + app.post('/:cloud/services/userpools/resources/:id/users/:username/actions', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + await svc(c).updateUserPoolUser(cloud, c.req.param('id'), c.req.param('username'), await c.req.json()) + return c.json({ok: true}) + }) + }) + + app.put('/:cloud/services/userpools/resources/:id/users/:username/groups', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json<{groups?: string[]}>() + await svc(c).setUserPoolUserGroups(cloud, c.req.param('id'), c.req.param('username'), body.groups as string[]) + return c.json({ok: true}) + }) + }) + + app.get('/:cloud/services/userpools/resources/:id/groups', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => c.json(await svc(c).listUserPoolGroups(cloud, c.req.param('id')))) + }) + + app.post('/:cloud/services/userpools/resources/:id/groups', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const group = await svc(c).createUserPoolGroup(cloud, c.req.param('id'), await c.req.json()) + return c.json(group, 201) + }) + }) + + app.delete('/:cloud/services/userpools/resources/:id/groups/:groupName', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + await svc(c).deleteUserPoolGroup(cloud, c.req.param('id'), c.req.param('groupName')) + return c.json({ok: true}) + }) + }) + + app.post('/:cloud/services/userpools/resources/:id/sign-in-test', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => c.json(await svc(c).testUserPoolSignIn(cloud, c.req.param('id'), await c.req.json()))) + }) + app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -1157,6 +1212,16 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) + app.get('/:cloud/services/:service/resources/:id/items/:itemId', async (c) => { + const target = childTarget(c) + if (!target) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + const item = await svc(c).getFlatItem(target.cloud, target.service, c.req.param('id'), c.req.param('itemId')) + return item ? c.json(item) : c.json({error: 'Item not found'}, 404) + }) + }) + app.delete('/:cloud/services/:service/resources/:id/items/:itemId', async (c) => { const target = childTarget(c) if (!target) return c.json({error: 'Unknown cloud or service'}, 404) diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index a20ba5a..d1c0653 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -77,6 +77,11 @@ import type { StorageObjectDownload, StorageObjectList, UpdateResourceInput, + CreateUserPoolGroupInput, + UserPoolGroup, + UserPoolSignInTestInput, + UserPoolSignInTestResult, + UserPoolUserAction, } from '../cloud-spi/types' import type { ChildCollection, @@ -664,6 +669,12 @@ export class CloudProxyService { return this.requireItems(cloud, service).listItems(resourceId, page) } + async getFlatItem(cloud: CloudProvider, service: CloudServiceType, resourceId: string, itemId: string): Promise { + const items = this.requireItems(cloud, service) + if (!items.getItem) throw new NotSupportedError(`Reading one item is not supported for ${cloud}/${service}`) + return items.getItem(resourceId, itemId) + } + async putFlatItem(cloud: CloudProvider, service: CloudServiceType, resourceId: string, body: Record): Promise { const items = this.requireItems(cloud, service) if (!items.putItem) throw new NotSupportedError(`Writing items is not supported for ${cloud}/${service}`) @@ -784,6 +795,42 @@ export class CloudProxyService { return adapter.filterLogEvents(logGroupName, input, page) } + async updateUserPoolUser(cloud: CloudProvider, poolId: string, username: string, input: UserPoolUserAction): Promise { + const adapter = this.requireAdapter(cloud, 'userpools') + if (!adapter.updateUserPoolUser) throw new NotSupportedError(`User actions are not supported for ${cloud}/userpools`) + await adapter.updateUserPoolUser(poolId, username, input) + } + + async setUserPoolUserGroups(cloud: CloudProvider, poolId: string, username: string, groups: string[]): Promise { + const adapter = this.requireAdapter(cloud, 'userpools') + if (!adapter.setUserPoolUserGroups) throw new NotSupportedError(`User groups are not supported for ${cloud}/userpools`) + await adapter.setUserPoolUserGroups(poolId, username, groups) + } + + async listUserPoolGroups(cloud: CloudProvider, poolId: string): Promise { + const adapter = this.requireAdapter(cloud, 'userpools') + if (!adapter.listUserPoolGroups) throw new NotSupportedError(`Groups are not supported for ${cloud}/userpools`) + return adapter.listUserPoolGroups(poolId) + } + + async createUserPoolGroup(cloud: CloudProvider, poolId: string, input: CreateUserPoolGroupInput): Promise { + const adapter = this.requireAdapter(cloud, 'userpools') + if (!adapter.createUserPoolGroup) throw new NotSupportedError(`Group creation is not supported for ${cloud}/userpools`) + return adapter.createUserPoolGroup(poolId, input) + } + + async deleteUserPoolGroup(cloud: CloudProvider, poolId: string, groupName: string): Promise { + const adapter = this.requireAdapter(cloud, 'userpools') + if (!adapter.deleteUserPoolGroup) throw new NotSupportedError(`Group deletion is not supported for ${cloud}/userpools`) + await adapter.deleteUserPoolGroup(poolId, groupName) + } + + async testUserPoolSignIn(cloud: CloudProvider, poolId: string, input: UserPoolSignInTestInput): Promise { + const adapter = this.requireAdapter(cloud, 'userpools') + if (!adapter.testUserPoolSignIn) throw new NotSupportedError(`Test sign-in is not supported for ${cloud}/userpools`) + return adapter.testUserPoolSignIn(poolId, input) + } + async listKubernetesNodegroups(cloud: CloudProvider, clusterId: string): Promise { const adapter = this.requireAdapter(cloud, 'k8s') if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`) From 43f4cae9b57c69312dd5ef63c7570cd92c7c1b06 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Fri, 23 Oct 2026 17:54:15 -0400 Subject: [PATCH 2/3] fix(api): count Cognito pool users directly The runtime leaves EstimatedNumberOfUsers at 0, so the pool metadata now carries a userCount from ListUsers (capped at 1000), which the delete confirmation reads. --- .../adapter-aws/AwsUserPoolsAdapter.test.ts | 13 +++++++++++ .../src/adapter-aws/AwsUserPoolsAdapter.ts | 23 ++++++++++++++++--- packages/api/src/cloud-spi/userPoolsSchema.ts | 2 +- 3 files changed, 34 insertions(+), 4 deletions(-) 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'}, ], } From bafe799d00fe7d58de2b862950d1984b055f2ed0 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Fri, 23 Oct 2026 17:54:15 -0400 Subject: [PATCH 3/3] feat(frontend): manage Cognito users and groups, and test sign-in The Cognito panel gains a test sign-in that shows the decoded ID and access token claims, a groups section, and a per-user panel to enable or disable, confirm, sign out everywhere, reset or set a password and change group membership. Delete confirmations (row and bulk) now say what else goes, driven by the schema's deleteImpact, and the inspector follows list refreshes instead of the click-time snapshot. --- packages/frontend/src/api/api.ts | 60 ++++++++ packages/frontend/src/api/cloudProxyClient.ts | 97 +++++++++++++ .../src/components/CognitoGroupsSection.tsx | 64 ++++++++ .../src/components/CognitoSignInTest.tsx | 73 ++++++++++ .../src/components/CognitoUserDetail.tsx | 137 ++++++++++++++++++ .../src/components/CognitoUsersPanel.test.tsx | 80 +++++++++- .../src/components/CognitoUsersPanel.tsx | 39 ++++- .../src/components/DynamicResourceView.tsx | 8 +- .../src/components/ResourceTable.test.tsx | 37 +++++ .../frontend/src/components/ResourceTable.tsx | 7 + packages/frontend/src/index.css | 104 +++++++++++++ .../frontend/src/lib/deleteImpact.test.ts | 22 +++ packages/frontend/src/lib/deleteImpact.ts | 25 ++++ packages/frontend/src/types/resource.ts | 32 ++++ packages/frontend/src/types/schema.ts | 4 + 15 files changed, 773 insertions(+), 16 deletions(-) create mode 100644 packages/frontend/src/components/CognitoGroupsSection.tsx create mode 100644 packages/frontend/src/components/CognitoSignInTest.tsx create mode 100644 packages/frontend/src/components/CognitoUserDetail.tsx create mode 100644 packages/frontend/src/components/ResourceTable.test.tsx create mode 100644 packages/frontend/src/lib/deleteImpact.test.ts create mode 100644 packages/frontend/src/lib/deleteImpact.ts diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index 0a4f2f1..c7673ce 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -130,6 +130,16 @@ export const apiEndpointKeys = { messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"}, moveTasks: "clouds.services.messaging.move-tasks", }, + userpools: { + userAction: "clouds.services.userpools.users.action", + userGroups: "clouds.services.userpools.users.groups", + groups: { + list: "clouds.services.userpools.groups.list", + create: "clouds.services.userpools.groups.create", + delete: "clouds.services.userpools.groups.delete", + }, + signInTest: "clouds.services.userpools.sign-in-test", + }, k8s: { nodegroups: { list: "clouds.services.k8s.nodegroups.list", @@ -154,6 +164,7 @@ export const apiEndpointKeys = { }, }, flatItems: { + get: "clouds.services.flatItems.get", list: "clouds.services.flatItems.list", query: "clouds.services.flatItems.query", create: "clouds.services.flatItems.create", @@ -829,6 +840,48 @@ export const endpointRegistry: EndpointRegistry = new Map([ method: "GET", }, ], + [ + apiEndpointKeys.clouds.userpools.userAction, + { + path: "/clouds/:cloud/services/userpools/resources/:id/users/:username/actions", + method: "POST", + }, + ], + [ + apiEndpointKeys.clouds.userpools.userGroups, + { + path: "/clouds/:cloud/services/userpools/resources/:id/users/:username/groups", + method: "PUT", + }, + ], + [ + apiEndpointKeys.clouds.userpools.groups.list, + { + path: "/clouds/:cloud/services/userpools/resources/:id/groups", + method: "GET", + }, + ], + [ + apiEndpointKeys.clouds.userpools.groups.create, + { + path: "/clouds/:cloud/services/userpools/resources/:id/groups", + method: "POST", + }, + ], + [ + apiEndpointKeys.clouds.userpools.groups.delete, + { + path: "/clouds/:cloud/services/userpools/resources/:id/groups/:groupName", + method: "DELETE", + }, + ], + [ + apiEndpointKeys.clouds.userpools.signInTest, + { + path: "/clouds/:cloud/services/userpools/resources/:id/sign-in-test", + method: "POST", + }, + ], [ apiEndpointKeys.clouds.k8s.nodegroups.list, { @@ -998,6 +1051,13 @@ export const endpointRegistry: EndpointRegistry = new Map([ method: "GET", }, ], + [ + apiEndpointKeys.clouds.flatItems.get, + { + path: "/clouds/:cloud/services/:service/resources/:id/items/:itemId", + method: "GET", + }, + ], [ apiEndpointKeys.clouds.flatItems.list, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 7a387e3..0f5da65 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -27,6 +27,11 @@ import type { DatabaseSnapshot, KubernetesFargateProfile, KubernetesNodegroup, + CreateUserPoolGroupInput, + UserPoolGroup, + UserPoolSignInTestInput, + UserPoolSignInTestResult, + UserPoolUserAction, LogsInsightsQueryInput, LogsInsightsQueryResult, LogsFilterInput, @@ -1377,6 +1382,98 @@ export async function deleteFlatItem( ); } +export async function getFlatItem( + cloud: CloudProvider, + service: CloudServiceType, + resourceId: string, + itemId: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.flatItems.get, + requestOptions(cloud, service, { signal }), + { cloud, service, id: resourceId, itemId }, + ); + return res.data; +} + +export async function updateUserPoolUser( + cloud: CloudProvider, + poolId: string, + username: string, + input: UserPoolUserAction, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.userpools.userAction, + requestOptions(cloud, "userpools", { body: input }), + { cloud, id: poolId, username }, + ); +} + +export async function setUserPoolUserGroups( + cloud: CloudProvider, + poolId: string, + username: string, + groups: string[], +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.userpools.userGroups, + requestOptions(cloud, "userpools", { body: { groups } }), + { cloud, id: poolId, username }, + ); +} + +export async function listUserPoolGroups( + cloud: CloudProvider, + poolId: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.userpools.groups.list, + requestOptions(cloud, "userpools", { signal }), + { cloud, id: poolId }, + ); + return res.data; +} + +export async function createUserPoolGroup( + cloud: CloudProvider, + poolId: string, + input: CreateUserPoolGroupInput, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.userpools.groups.create, + requestOptions(cloud, "userpools", { body: input }), + { cloud, id: poolId }, + ); + return res.data; +} + +export async function deleteUserPoolGroup( + cloud: CloudProvider, + poolId: string, + groupName: string, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.userpools.groups.delete, + requestOptions(cloud, "userpools"), + { cloud, id: poolId, groupName }, + ); +} + +export async function testUserPoolSignIn( + cloud: CloudProvider, + poolId: string, + input: UserPoolSignInTestInput, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.userpools.signInTest, + requestOptions(cloud, "userpools", { body: input }), + { cloud, id: poolId }, + ); + return res.data; +} + export async function putFlatItem( cloud: CloudProvider, service: CloudServiceType, diff --git a/packages/frontend/src/components/CognitoGroupsSection.tsx b/packages/frontend/src/components/CognitoGroupsSection.tsx new file mode 100644 index 0000000..0fa27f9 --- /dev/null +++ b/packages/frontend/src/components/CognitoGroupsSection.tsx @@ -0,0 +1,64 @@ +import {Trash2} from 'lucide-react' +import {useMutation, useQueryClient, type UseQueryResult} from '@tanstack/react-query' +import {useState} from 'react' +import {createUserPoolGroup, deleteUserPoolGroup} from '@/api/cloudProxyClient' +import type {UserPoolGroup} from '@/types/resource' + +type Props = { + poolId: string + groupsQuery: UseQueryResult + groupsKey: unknown[] + runtimeReachable: boolean +} + +export function CognitoGroupsSection({poolId, groupsQuery, groupsKey, runtimeReachable}: Props) { + const queryClient = useQueryClient() + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [precedence, setPrecedence] = useState('') + const refresh = () => queryClient.invalidateQueries({queryKey: groupsKey}) + + const createMutation = useMutation({ + mutationFn: () => createUserPoolGroup('aws', poolId, { + name: name.trim(), + ...(description.trim() ? {description: description.trim()} : {}), + ...(precedence.trim() ? {precedence: Number(precedence)} : {}), + }), + onSuccess: () => { + setName('') + setDescription('') + setPrecedence('') + return refresh() + }, + }) + const deleteMutation = useMutation({ + mutationFn: (groupName: string) => deleteUserPoolGroup('aws', poolId, groupName), + onSuccess: refresh, + }) + + const groups = groupsQuery.data ?? [] + const pending = createMutation.isPending || deleteMutation.isPending + const error = groupsQuery.error ?? createMutation.error ?? deleteMutation.error + + return <> +
+

Groups

Group names appear in the cognito:groups token claim, so apps can check roles
+
+
{ event.preventDefault(); if (name.trim()) createMutation.mutate() }}> + + + +
+
+ {error instanceof Error &&

{error.message}

} + {groupsQuery.isSuccess && groups.length === 0 &&

No groups yet.

} + {groups.length > 0 &&
{groups.map((group) => ( + + + + + + + ))}
NameDescriptionPrecedence
{group.name}{group.description || '—'}{group.precedence ?? '—'}
} + +} diff --git a/packages/frontend/src/components/CognitoSignInTest.tsx b/packages/frontend/src/components/CognitoSignInTest.tsx new file mode 100644 index 0000000..e6f9bc0 --- /dev/null +++ b/packages/frontend/src/components/CognitoSignInTest.tsx @@ -0,0 +1,73 @@ +import {useMutation} from '@tanstack/react-query' +import {useEffect, useState} from 'react' +import {testUserPoolSignIn} from '@/api/cloudProxyClient' + +type Props = { + poolId: string + appClients: Array<{clientId: string; clientName: string}> + loginLabel: string + runtimeReachable: boolean +} + +const KEY_CLAIMS = ['sub', 'email', 'cognito:username', 'cognito:groups', 'client_id', 'aud', 'token_use', 'exp'] + +export function CognitoSignInTest({poolId, appClients, loginLabel, runtimeReachable}: Props) { + const [clientId, setClientId] = useState(appClients[0]?.clientId ?? '') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + + useEffect(() => { + if (!appClients.some((client) => client.clientId === clientId)) setClientId(appClients[0]?.clientId ?? '') + }, [appClients, clientId]) + + const signIn = useMutation({ + mutationFn: () => testUserPoolSignIn('aws', poolId, {clientId, username: username.trim(), password}), + }) + const result = signIn.data + + if (appClients.length === 0) return null + + return <> +
+

Test sign-in

Signs in the way an app does and shows what is inside the tokens
+
+
{ event.preventDefault(); if (clientId && username.trim() && password) signIn.mutate() }}> + + + +
+
+ {signIn.error instanceof Error &&

{signIn.error.message}

} + {result?.outcome === 'rejected' &&

Refused {result.code} {result.message}

} + {result?.outcome === 'challenge' &&

Cognito wants another step {result.challengeName} {challengeHint(result.challengeName)}

} + {result?.outcome === 'signed-in' &&
+

Signed in. {result.expiresIn ? `The ID and access tokens are valid for ${Math.round(result.expiresIn / 60)} minutes.` : ''}

+
+ + +
+
} + +} + +function ClaimsTable({title, claims}: {title: string; claims: Record}) { + const keys = [...KEY_CLAIMS.filter((key) => key in claims), ...Object.keys(claims).filter((key) => !KEY_CLAIMS.includes(key)).sort()] + return
+

{title}

+ {keys.map((key) => ( + + ))}
{key}{formatClaim(key, claims[key])}
+
+} + +function formatClaim(key: string, value: unknown): string { + if ((key === 'exp' || key === 'iat' || key === 'auth_time') && typeof value === 'number') return `${value} (${new Date(value * 1000).toLocaleString()})` + if (Array.isArray(value)) return value.join(', ') + return typeof value === 'string' ? value : JSON.stringify(value) +} + +function challengeHint(challenge: string): string { + if (challenge === 'NEW_PASSWORD_REQUIRED') return 'The password is temporary: the user must choose a new one before getting tokens.' + if (challenge.includes('MFA') || challenge === 'SOFTWARE_TOKEN_MFA') return 'The user must enter a second-factor code.' + return 'The app has to answer this challenge before Cognito issues tokens.' +} diff --git a/packages/frontend/src/components/CognitoUserDetail.tsx b/packages/frontend/src/components/CognitoUserDetail.tsx new file mode 100644 index 0000000..dd144bd --- /dev/null +++ b/packages/frontend/src/components/CognitoUserDetail.tsx @@ -0,0 +1,137 @@ +import {X} from 'lucide-react' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {useEffect, useState} from 'react' +import {getFlatItem, setUserPoolUserGroups, updateUserPoolUser} from '@/api/cloudProxyClient' +import {useAccountId} from '@/lib/accountStore' +import {formatDateTime} from '@/lib/format' +import type {UserPoolGroup, UserPoolUserAction} from '@/types/resource' + +type Props = { + poolId: string + username: string + groups: UserPoolGroup[] + usersKey: unknown[] + runtimeReachable: boolean + onClose: () => void +} + +const ACTION_DONE: Record = { + enable: 'User enabled.', + disable: 'User disabled. New sign-ins are refused; tokens already issued last until they expire.', + confirm: 'User confirmed.', + resetPassword: 'Password reset. The user must use a reset code to sign in again.', + signOut: 'Signed out everywhere. Refresh tokens no longer work.', + setPassword: 'Password set.', +} + +export function CognitoUserDetail({poolId, username, groups, usersKey, runtimeReachable, onClose}: Props) { + const accountId = useAccountId() + const queryClient = useQueryClient() + const userKey = ['cognito-user', accountId, poolId, username] + const [selectedGroups, setSelectedGroups] = useState([]) + const [password, setPassword] = useState('') + const [permanent, setPermanent] = useState(true) + const [notice, setNotice] = useState('') + + const userQuery = useQuery({ + queryKey: userKey, + queryFn: ({signal}) => getFlatItem('aws', 'userpools', poolId, username, signal), + enabled: runtimeReachable, + }) + const user = userQuery.data?.body + const currentGroups = stringList(user?.groups) + + // Reset the checkboxes only when the stored membership changes, not on every render. + const storedGroups = currentGroups.join('\n') + useEffect(() => { + setSelectedGroups(storedGroups ? storedGroups.split('\n') : []) + }, [storedGroups]) + + const refresh = () => Promise.all([queryClient.invalidateQueries({queryKey: userKey}), queryClient.invalidateQueries({queryKey: usersKey})]) + const actionMutation = useMutation({ + mutationFn: (input: UserPoolUserAction) => updateUserPoolUser('aws', poolId, username, input), + onSuccess: (_result, input) => { + setNotice(ACTION_DONE[input.action]) + if (input.action === 'setPassword') setPassword('') + return refresh() + }, + }) + const groupsMutation = useMutation({ + mutationFn: () => setUserPoolUserGroups('aws', poolId, username, selectedGroups), + onSuccess: () => { + setNotice('Groups saved. They appear in tokens issued from the next sign-in.') + return refresh() + }, + }) + + const pending = actionMutation.isPending || groupsMutation.isPending + const error = userQuery.error ?? actionMutation.error ?? groupsMutation.error + const run = (input: UserPoolUserAction) => { setNotice(''); actionMutation.mutate(input) } + const groupsChanged = selectedGroups.slice().sort().join('\n') !== currentGroups.slice().sort().join('\n') + const name = stringValue(user?.email) || username + const attributes = Object.entries((user?.attributes ?? {}) as Record) + const disabled = !runtimeReachable || pending + + return
+
+

User

{name}
+ +
+ {userQuery.isLoading &&

Loading user

} + {user &&
+
+
Status
{stringValue(user.status) || '—'}
+
Enabled
{user.enabled === false ? 'No' : 'Yes'}
+
Email verified
{user.emailVerified ? 'Yes' : 'No'}
+
Created
{formatDateTime(userQuery.data?.timestamp ?? null) ?? '—'}
+
Username
{username}
+
+ +
+

Actions

+
+ {user.enabled === false + ? + : } + {user.status === 'UNCONFIRMED' && } + + +
+
{ event.preventDefault(); if (password) run({action: 'setPassword', password, permanent}) }}> + + + +
+
+ +
+

Groups

+ {groups.length === 0 &&

This pool has no groups. Create one in Groups above.

} + {groups.map((group) => ( + + ))} + {groups.length > 0 && } +
+ + {attributes.length > 0 &&
+

Attributes

+ {attributes.sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ( + + ))}
{key}{value}
+
} +
} + {notice &&

{notice}

} + {error instanceof Error &&

{error.message}

} +
+} + +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [] +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value : '' +} diff --git a/packages/frontend/src/components/CognitoUsersPanel.test.tsx b/packages/frontend/src/components/CognitoUsersPanel.test.tsx index d716492..6ecbbaf 100644 --- a/packages/frontend/src/components/CognitoUsersPanel.test.tsx +++ b/packages/frontend/src/components/CognitoUsersPanel.test.tsx @@ -1,5 +1,5 @@ import {QueryClient, QueryClientProvider} from '@tanstack/react-query' -import {render, screen} from '@testing-library/react' +import {render, screen, within} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {beforeEach, expect, test, vi} from 'vitest' import {CognitoUsersPanel} from './CognitoUsersPanel' @@ -9,7 +9,14 @@ const api = vi.hoisted(() => ({ deleteFlatItem: vi.fn(), getCloudResource: vi.fn(), listFlatItems: vi.fn(), + listUserPoolGroups: vi.fn(), putFlatItem: vi.fn(), + getFlatItem: vi.fn(), + updateUserPoolUser: vi.fn(), + setUserPoolUserGroups: vi.fn(), + createUserPoolGroup: vi.fn(), + deleteUserPoolGroup: vi.fn(), + testUserPoolSignIn: vi.fn(), })) vi.mock('@/api/cloudProxyClient', () => api) @@ -39,6 +46,11 @@ beforeEach(() => { api.listFlatItems.mockResolvedValue({items: [alice], nextCursor: null}) api.putFlatItem.mockResolvedValue(alice) api.deleteFlatItem.mockResolvedValue(undefined) + api.listUserPoolGroups.mockResolvedValue([{name: 'admins', description: 'Full access', precedence: 0, createdAt: null}]) + api.getFlatItem.mockResolvedValue({...alice, body: {...alice.body, emailVerified: true, groups: [], attributes: {sub: alice.id, email: 'alice@example.com'}}}) + api.updateUserPoolUser.mockResolvedValue(undefined) + api.setUserPoolUserGroups.mockResolvedValue(undefined) + api.createUserPoolGroup.mockResolvedValue({name: 'editors', description: null, precedence: null, createdAt: null}) }) test('shows the app client id and the pool users', async () => { @@ -58,9 +70,10 @@ test('creates an email user who can sign in immediately', async () => { 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'})) + const form = screen.getByRole('form', {name: 'Create user'}) + await user.type(within(form).getByLabelText('Email'), 'bob@example.com') + await user.type(within(form).getByLabelText('Password'), 'Passw0rd!') + await user.click(within(form).getByRole('button', {name: 'Create user'})) expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {email: 'bob@example.com', password: 'Passw0rd!', permanent: true}) }) @@ -70,10 +83,12 @@ test('creates a username user with a temporary password when asked', async () => 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'})) + await screen.findByText('a8e5c7d38d3b4a1a97c9d822de') + const form = screen.getByRole('form', {name: 'Create user'}) + await user.type(within(form).getByLabelText('Username'), 'bob') + await user.type(within(form).getByLabelText('Password'), 'Passw0rd!') + await user.click(within(form).getByLabelText(/User can sign in now/)) + await user.click(within(form).getByRole('button', {name: 'Create user'})) expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {username: 'bob', password: 'Passw0rd!', permanent: false}) }) @@ -87,3 +102,52 @@ test('deletes a user by its stored username', async () => { expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, alice.id) }) + +test('manages a user: disables it and puts it in a group', async () => { + const user = userEvent.setup() + api.getCloudResource.mockResolvedValue(pool('email')) + renderPanel() + + await user.click(await screen.findByRole('button', {name: 'Manage user alice@example.com'})) + await user.click(await screen.findByRole('button', {name: 'Disable'})) + expect(api.updateUserPoolUser).toHaveBeenCalledWith('aws', summary.id, alice.id, {action: 'disable'}) + expect(await screen.findByText(/User disabled/)).toBeInTheDocument() + + const groupBox = screen.getByRole('checkbox', {name: 'admins'}) + await user.click(groupBox) + await user.click(screen.getByRole('button', {name: 'Save groups'})) + expect(api.setUserPoolUserGroups).toHaveBeenCalledWith('aws', summary.id, alice.id, ['admins']) +}) + +test('creates a group with its precedence', async () => { + const user = userEvent.setup() + api.getCloudResource.mockResolvedValue(pool('email')) + renderPanel() + + await user.type(await screen.findByLabelText('Group name'), 'editors') + await user.type(screen.getByLabelText(/^Precedence/), '5') + await user.click(screen.getByRole('button', {name: 'Create group'})) + + expect(api.createUserPoolGroup).toHaveBeenCalledWith('aws', summary.id, {name: 'editors', precedence: 5}) +}) + +test('test sign-in shows the decoded claims, or why Cognito refused', async () => { + const user = userEvent.setup() + api.getCloudResource.mockResolvedValue(pool('email')) + api.testUserPoolSignIn + .mockResolvedValueOnce({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'}) + .mockResolvedValueOnce({outcome: 'signed-in', expiresIn: 3600, idToken: {sub: alice.id, email: 'alice@example.com', 'cognito:groups': ['admins']}, accessToken: {sub: alice.id, client_id: 'a8e5c7d38d3b4a1a97c9d822de'}}) + renderPanel() + + await screen.findByText('a8e5c7d38d3b4a1a97c9d822de') + const form = screen.getByRole('form', {name: 'Test sign-in'}) + await user.type(within(form).getByLabelText('Email'), 'alice@example.com') + await user.type(within(form).getByLabelText('Password'), 'wrong') + await user.click(within(form).getByRole('button', {name: 'Try sign-in'})) + expect(await screen.findByText('Incorrect username or password.')).toBeInTheDocument() + + await user.click(within(form).getByRole('button', {name: 'Try sign-in'})) + expect(await screen.findByText(/valid for 60 minutes/)).toBeInTheDocument() + expect(screen.getByText('cognito:groups', {selector: 'th code'})).toBeInTheDocument() + expect(api.testUserPoolSignIn).toHaveBeenLastCalledWith('aws', summary.id, {clientId: 'a8e5c7d38d3b4a1a97c9d822de', username: 'alice@example.com', password: 'wrong'}) +}) diff --git a/packages/frontend/src/components/CognitoUsersPanel.tsx b/packages/frontend/src/components/CognitoUsersPanel.tsx index bb6fcb5..1ef2c70 100644 --- a/packages/frontend/src/components/CognitoUsersPanel.tsx +++ b/packages/frontend/src/components/CognitoUsersPanel.tsx @@ -1,7 +1,10 @@ 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 {deleteFlatItem, getCloudResource, listFlatItems, listUserPoolGroups, putFlatItem} from '@/api/cloudProxyClient' +import {CognitoGroupsSection} from '@/components/CognitoGroupsSection' +import {CognitoSignInTest} from '@/components/CognitoSignInTest' +import {CognitoUserDetail} from '@/components/CognitoUserDetail' import {CopyValueButton} from '@/components/CopyValueButton' import {useAccountId} from '@/lib/accountStore' import {formatDateTime} from '@/lib/format' @@ -20,7 +23,9 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) { const [login, setLogin] = useState('') const [password, setPassword] = useState('') const [permanent, setPermanent] = useState(true) + const [openUser, setOpenUser] = useState(null) const usersKey = ['cognito-users', accountId, resource?.id] + const groupsKey = ['cognito-groups', accountId, resource?.id] // The table row is a list summary; sign-in method and app clients come from the full pool. const poolQuery = useQuery({ @@ -33,13 +38,25 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) { queryFn: ({signal}) => listFlatItems('aws', 'userpools', resource?.id ?? '', undefined, signal), enabled: Boolean(resource) && runtimeReachable, }) + const groupsQuery = useQuery({ + queryKey: groupsKey, + queryFn: ({signal}) => listUserPoolGroups('aws', resource?.id ?? '', signal), + enabled: Boolean(resource) && runtimeReachable, + }) useEffect(() => { setLogin('') setPassword('') setPermanent(true) + setOpenUser(null) }, [resource?.id]) + // The pool list carries each pool's user count, which the delete confirmation reads. + const refreshUsers = () => Promise.all([ + queryClient.invalidateQueries({queryKey: usersKey}), + queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'userpools']}), + ]) + const signInWithEmail = poolQuery.data?.metadata.signInWith !== 'username' const createMutation = useMutation({ mutationFn: () => putFlatItem('aws', 'userpools', resource?.id ?? '', { @@ -50,12 +67,15 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) { onSuccess: () => { setLogin('') setPassword('') - return queryClient.invalidateQueries({queryKey: usersKey}) + return refreshUsers() }, }) const deleteMutation = useMutation({ mutationFn: (username: string) => deleteFlatItem('aws', 'userpools', resource?.id ?? '', username), - onSuccess: () => queryClient.invalidateQueries({queryKey: usersKey}), + onSuccess: (_result, username) => { + if (openUser === username) setOpenUser(null) + return refreshUsers() + }, }) if (!resource) { @@ -77,11 +97,15 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) { {client.clientName || '—'}{client.clientId} ))}} + + + +
-

Users

{users.length} users in {resource.name}
- +

Users

{users.length} {users.length === 1 ? 'user' : 'users'} in {resource.name}
+
-
{ event.preventDefault(); if (login.trim() && password) createMutation.mutate() }}> + { event.preventDefault(); if (login.trim() && password) createMutation.mutate() }}> @@ -97,9 +121,10 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) { {stringValue(user.body.status) || '—'} {user.body.enabled === false ? 'No' : 'Yes'} {formatDateTime(user.timestamp) ?? '—'} - + })}} + {openUser && setOpenUser(null)}/>} } diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index c8c201d..b2c418f 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -23,6 +23,7 @@ import { StorageObjectBrowser } from "@/components/StorageObjectBrowser"; import { S3BucketWebsitePanel } from "@/components/S3BucketWebsitePanel"; import { ComputePanel, LaunchInstanceForm } from "@/components/ComputePanel"; import { NetworkingPanel } from "@/components/NetworkingPanel"; +import { describeDeleteImpact } from "@/lib/deleteImpact"; import { getPath } from "@/lib/resourcePath"; import { capabilityEnabled, @@ -574,12 +575,14 @@ export function DynamicResourceView({ const resources = resourcesQuery.data ?? []; const canCreate = schema.actions.includes("create"); const canDelete = schema.actions.includes("delete"); + const deleteImpactText = describeDeleteImpact(schema.deleteImpact, selectedItems); const isAllSelected = resources.length > 0 && resources.every((r) => selectedItems.some((s) => s.id === r.id)); + // Prefer the latest listed copy, so the inspector follows refreshes instead of the click-time snapshot. const activeSelected = selected?.cloud === cloud && selected.service === service - ? selected + ? resources.find((resource) => resource.id === selected.id) ?? selected : undefined; const runtimeReachable = cloudStatus?.runtime === "reachable"; const resourceCapabilityInputs = @@ -744,6 +747,9 @@ export function DynamicResourceView({ {canDelete && ( deleteConfirm ? ( <> + {deleteImpactText && ( + {deleteImpactText} + )}