feat(api): Cognito user actions, groups and test sign-in #9

Merged
Zakaria merged 3 commits from feat/cognito-round-2 into main 2026-10-23 22:56:19 +00:00
10 changed files with 569 additions and 4 deletions
Showing only changes of commit b4c2cadf5c - Show all commits
@@ -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<string, unknown>
@@ -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, unknown>): 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'})
})
})
@@ -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<void> {
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<void> {
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<UserPoolGroup[]> {
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<UserPoolGroup> {
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<void> {
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<UserPoolSignInTestResult> {
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<string[]> {
return listGroupNamesForUser(this.cognito, poolId, username)
}
private async listClients(poolId: string): Promise<UserPoolClientDescription[]> {
const clients: UserPoolClientDescription[] = []
let nextToken: string | undefined
@@ -169,7 +313,9 @@ class UserStore implements ItemStoreAdapter {
async getItem(poolId: string, username: string): Promise<ChildItem | null> {
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<string[]> {
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<string, unknown> {
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<string, unknown>
}
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 [{
@@ -137,6 +137,7 @@ describe('cognito errors carry HTTP 400 and keep their meaning', () => {
['UserNotFoundException', 404],
['UsernameExistsException', 409],
['AliasExistsException', 409],
['GroupExistsException', 409],
['InvalidPasswordException', 400],
]
@@ -60,6 +60,7 @@ const CONFLICT_NAMES = new Set([
'DeleteConflictException',
'UsernameExistsException',
'AliasExistsException',
'GroupExistsException',
])
const ACCESS_DENIED_NAMES = new Set([
+53
View File
@@ -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<TAction extends string> {
@@ -214,6 +221,7 @@ export interface ServiceSchema {
objectActions?: CapabilitySchema<ObjectActionName>[]
databaseActions?: CapabilitySchema<DatabaseActionName>[]
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
// 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<string, unknown>; accessToken: Record<string, unknown>}
| {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<KubernetesFargateProfile[]>
createKubernetesFargateProfile?(clusterId: string, input: CreateKubernetesFargateProfileInput): Promise<KubernetesFargateProfile>
deleteKubernetesFargateProfile?(clusterId: string, profileId: string): Promise<void>
updateUserPoolUser?(poolId: string, username: string, input: UserPoolUserAction): Promise<void>
setUserPoolUserGroups?(poolId: string, username: string, groups: string[]): Promise<void>
listUserPoolGroups?(poolId: string): Promise<UserPoolGroup[]>
createUserPoolGroup?(poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup>
deleteUserPoolGroup?(poolId: string, groupName: string): Promise<void>
testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult>
/** Clears the provider's locally captured email inbox, if it exposes one. */
clearEmailInbox?(): Promise<void>
// AppConfig nested resources, keyed by the ids the provider needs. Inspect
@@ -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'},
],
}
}
+20 -1
View File
@@ -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<KubernetesActionName, keyof CloudServ
deleteFargateProfile: 'deleteKubernetesFargateProfile',
}
const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
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 ?? []),
]
+53
View File
@@ -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', {
+65
View File
@@ -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)
@@ -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<ChildItem | null> {
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<string, unknown>): Promise<ChildItem> {
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<void> {
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<void> {
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<UserPoolGroup[]> {
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<UserPoolGroup> {
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<void> {
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<UserPoolSignInTestResult> {
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<KubernetesNodegroup[]> {
const adapter = this.requireAdapter(cloud, 'k8s')
if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`)