From b4c2cadf5c391dd5a93df4abb11ddbb0fdeac004 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Fri, 23 Oct 2026 17:37:35 -0400 Subject: [PATCH] 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`)