feat(api): Cognito user actions, groups and test sign-in #9
@@ -1,5 +1,17 @@
|
|||||||
|
import {createHmac} from 'node:crypto'
|
||||||
import {describe, expect, test} from 'bun:test'
|
import {describe, expect, test} from 'bun:test'
|
||||||
import {
|
import {
|
||||||
|
AdminAddUserToGroupCommand,
|
||||||
|
AdminConfirmSignUpCommand,
|
||||||
|
AdminDisableUserCommand,
|
||||||
|
AdminListGroupsForUserCommand,
|
||||||
|
AdminRemoveUserFromGroupCommand,
|
||||||
|
AdminUserGlobalSignOutCommand,
|
||||||
|
CreateGroupCommand,
|
||||||
|
DeleteGroupCommand,
|
||||||
|
DescribeUserPoolClientCommand,
|
||||||
|
InitiateAuthCommand,
|
||||||
|
ListGroupsCommand,
|
||||||
AdminCreateUserCommand,
|
AdminCreateUserCommand,
|
||||||
AdminDeleteUserCommand,
|
AdminDeleteUserCommand,
|
||||||
AdminGetUserCommand,
|
AdminGetUserCommand,
|
||||||
@@ -14,7 +26,7 @@ import {
|
|||||||
type CognitoIdentityProviderClient,
|
type CognitoIdentityProviderClient,
|
||||||
} from '@aws-sdk/client-cognito-identity-provider'
|
} from '@aws-sdk/client-cognito-identity-provider'
|
||||||
import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter'
|
import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter'
|
||||||
import {ValidationError} from '../cloud-spi/errors'
|
import {ConflictError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
type SendResult = Record<string, unknown>
|
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})
|
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 {
|
import {
|
||||||
|
AdminAddUserToGroupCommand,
|
||||||
|
AdminConfirmSignUpCommand,
|
||||||
AdminCreateUserCommand,
|
AdminCreateUserCommand,
|
||||||
AdminDeleteUserCommand,
|
AdminDeleteUserCommand,
|
||||||
|
AdminDisableUserCommand,
|
||||||
|
AdminEnableUserCommand,
|
||||||
AdminGetUserCommand,
|
AdminGetUserCommand,
|
||||||
|
AdminListGroupsForUserCommand,
|
||||||
|
AdminRemoveUserFromGroupCommand,
|
||||||
|
AdminResetUserPasswordCommand,
|
||||||
AdminSetUserPasswordCommand,
|
AdminSetUserPasswordCommand,
|
||||||
|
AdminUserGlobalSignOutCommand,
|
||||||
|
CreateGroupCommand,
|
||||||
CreateUserPoolClientCommand,
|
CreateUserPoolClientCommand,
|
||||||
CreateUserPoolCommand,
|
CreateUserPoolCommand,
|
||||||
|
DeleteGroupCommand,
|
||||||
DeleteUserPoolCommand,
|
DeleteUserPoolCommand,
|
||||||
|
DescribeUserPoolClientCommand,
|
||||||
DescribeUserPoolCommand,
|
DescribeUserPoolCommand,
|
||||||
|
InitiateAuthCommand,
|
||||||
|
ListGroupsCommand,
|
||||||
ListUserPoolClientsCommand,
|
ListUserPoolClientsCommand,
|
||||||
ListUserPoolsCommand,
|
ListUserPoolsCommand,
|
||||||
ListUsersCommand,
|
ListUsersCommand,
|
||||||
type AttributeType,
|
type AttributeType,
|
||||||
type CognitoIdentityProviderClient,
|
type CognitoIdentityProviderClient,
|
||||||
|
type GroupType,
|
||||||
type PasswordPolicyType,
|
type PasswordPolicyType,
|
||||||
type UserPoolClientDescription,
|
type UserPoolClientDescription,
|
||||||
type UserPoolDescriptionType,
|
type UserPoolDescriptionType,
|
||||||
@@ -19,8 +34,9 @@ import {
|
|||||||
type UserType,
|
type UserType,
|
||||||
} from '@aws-sdk/client-cognito-identity-provider'
|
} from '@aws-sdk/client-cognito-identity-provider'
|
||||||
import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections'
|
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 {
|
import {
|
||||||
|
USER_POOL_GROUP_NAME_PATTERN,
|
||||||
USER_POOL_NAME_PATTERN,
|
USER_POOL_NAME_PATTERN,
|
||||||
USER_POOL_PASSWORD_POLICIES,
|
USER_POOL_PASSWORD_POLICIES,
|
||||||
USER_POOL_SIGN_IN,
|
USER_POOL_SIGN_IN,
|
||||||
@@ -30,8 +46,13 @@ import type {
|
|||||||
CloudResource,
|
CloudResource,
|
||||||
CloudServiceAdapter,
|
CloudServiceAdapter,
|
||||||
CreateResourceInput,
|
CreateResourceInput,
|
||||||
|
CreateUserPoolGroupInput,
|
||||||
ResourceQuery,
|
ResourceQuery,
|
||||||
ServiceSchema,
|
ServiceSchema,
|
||||||
|
UserPoolGroup,
|
||||||
|
UserPoolSignInTestInput,
|
||||||
|
UserPoolSignInTestResult,
|
||||||
|
UserPoolUserAction,
|
||||||
} from '../cloud-spi/types'
|
} from '../cloud-spi/types'
|
||||||
|
|
||||||
/** Cognito caps every list page at 60. */
|
/** Cognito caps every list page at 60. */
|
||||||
@@ -51,6 +72,9 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|||||||
* symbol, matching AWS.
|
* symbol, matching AWS.
|
||||||
* - `InitiateAuth` with `USER_PASSWORD_AUTH` issues real tokens once a user's
|
* - `InitiateAuth` with `USER_PASSWORD_AUTH` issues real tokens once a user's
|
||||||
* password is permanent, so users created here can sign in immediately.
|
* 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
|
* 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.
|
* 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}))
|
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[]> {
|
private async listClients(poolId: string): Promise<UserPoolClientDescription[]> {
|
||||||
const clients: UserPoolClientDescription[] = []
|
const clients: UserPoolClientDescription[] = []
|
||||||
let nextToken: string | undefined
|
let nextToken: string | undefined
|
||||||
@@ -169,7 +313,9 @@ class UserStore implements ItemStoreAdapter {
|
|||||||
async getItem(poolId: string, username: string): Promise<ChildItem | null> {
|
async getItem(poolId: string, username: string): Promise<ChildItem | null> {
|
||||||
try {
|
try {
|
||||||
const user = await this.cognito.send(new AdminGetUserCommand({UserPoolId: poolId, Username: username}))
|
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) {
|
} catch (error) {
|
||||||
if (isMissing(error)) return null
|
if (isMissing(error)) return null
|
||||||
throw error
|
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[] {
|
function toSummaryResource(pool: UserPoolDescriptionType): CloudResource[] {
|
||||||
if (!pool.Id) return []
|
if (!pool.Id) return []
|
||||||
return [{
|
return [{
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ describe('cognito errors carry HTTP 400 and keep their meaning', () => {
|
|||||||
['UserNotFoundException', 404],
|
['UserNotFoundException', 404],
|
||||||
['UsernameExistsException', 409],
|
['UsernameExistsException', 409],
|
||||||
['AliasExistsException', 409],
|
['AliasExistsException', 409],
|
||||||
|
['GroupExistsException', 409],
|
||||||
['InvalidPasswordException', 400],
|
['InvalidPasswordException', 400],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const CONFLICT_NAMES = new Set([
|
|||||||
'DeleteConflictException',
|
'DeleteConflictException',
|
||||||
'UsernameExistsException',
|
'UsernameExistsException',
|
||||||
'AliasExistsException',
|
'AliasExistsException',
|
||||||
|
'GroupExistsException',
|
||||||
])
|
])
|
||||||
|
|
||||||
const ACCESS_DENIED_NAMES = new Set([
|
const ACCESS_DENIED_NAMES = new Set([
|
||||||
|
|||||||
@@ -171,6 +171,13 @@ export type KubernetesActionName =
|
|||||||
| 'listFargateProfiles'
|
| 'listFargateProfiles'
|
||||||
| 'createFargateProfile'
|
| 'createFargateProfile'
|
||||||
| 'deleteFargateProfile'
|
| 'deleteFargateProfile'
|
||||||
|
export type UserPoolActionName =
|
||||||
|
| 'updateUser'
|
||||||
|
| 'setUserGroups'
|
||||||
|
| 'listGroups'
|
||||||
|
| 'createGroup'
|
||||||
|
| 'deleteGroup'
|
||||||
|
| 'testSignIn'
|
||||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||||
|
|
||||||
export interface CapabilitySchema<TAction extends string> {
|
export interface CapabilitySchema<TAction extends string> {
|
||||||
@@ -214,6 +221,7 @@ export interface ServiceSchema {
|
|||||||
objectActions?: CapabilitySchema<ObjectActionName>[]
|
objectActions?: CapabilitySchema<ObjectActionName>[]
|
||||||
databaseActions?: CapabilitySchema<DatabaseActionName>[]
|
databaseActions?: CapabilitySchema<DatabaseActionName>[]
|
||||||
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
||||||
|
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
||||||
// Child levels advertise separately from the resource level because a
|
// Child levels advertise separately from the resource level because a
|
||||||
// store can be readable at the leaf and writable at the collection —
|
// store can be readable at the leaf and writable at the collection —
|
||||||
// CloudWatch Logs creates streams but cannot delete an event.
|
// CloudWatch Logs creates streams but cannot delete an event.
|
||||||
@@ -223,6 +231,11 @@ export interface ServiceSchema {
|
|||||||
filters: FieldSchema[]
|
filters: FieldSchema[]
|
||||||
columns: TableColumnSchema[]
|
columns: TableColumnSchema[]
|
||||||
updateFields?: FieldSchema[]
|
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 =
|
export type KnownResourceType =
|
||||||
@@ -411,6 +424,40 @@ export interface SqsMoveTaskResult {
|
|||||||
taskHandle: string
|
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. */
|
/** Provider-neutral representation of a managed Kubernetes node pool. */
|
||||||
export interface KubernetesNodegroup {
|
export interface KubernetesNodegroup {
|
||||||
id: string
|
id: string
|
||||||
@@ -917,6 +964,12 @@ export interface CloudServiceAdapter {
|
|||||||
listKubernetesFargateProfiles?(clusterId: string): Promise<KubernetesFargateProfile[]>
|
listKubernetesFargateProfiles?(clusterId: string): Promise<KubernetesFargateProfile[]>
|
||||||
createKubernetesFargateProfile?(clusterId: string, input: CreateKubernetesFargateProfileInput): Promise<KubernetesFargateProfile>
|
createKubernetesFargateProfile?(clusterId: string, input: CreateKubernetesFargateProfileInput): Promise<KubernetesFargateProfile>
|
||||||
deleteKubernetesFargateProfile?(clusterId: string, profileId: string): Promise<void>
|
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. */
|
/** Clears the provider's locally captured email inbox, if it exposes one. */
|
||||||
clearEmailInbox?(): Promise<void>
|
clearEmailInbox?(): Promise<void>
|
||||||
// AppConfig nested resources, keyed by the ids the provider needs. Inspect
|
// 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_NAME_PATTERN = '^[\\w\\s+=,.@-]{1,128}$'
|
||||||
export const USER_POOL_SIGN_IN = ['email', 'username'] as const
|
export const USER_POOL_SIGN_IN = ['email', 'username'] as const
|
||||||
export const USER_POOL_PASSWORD_POLICIES = ['default', 'relaxed'] 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[] = [
|
const userPoolColumns: TableColumnSchema[] = [
|
||||||
{name: 'name', label: 'Name'},
|
{name: 'name', label: 'Name'},
|
||||||
@@ -72,9 +74,21 @@ export function awsUserPoolsSchema(): ServiceSchema {
|
|||||||
{name: 'put', label: 'Create user', enabled: true, status: 'available', runtimeRequired: true},
|
{name: 'put', label: 'Create user', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
{name: 'delete', label: 'Delete user', enabled: true, status: 'available', runtimeRequired: true},
|
{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,
|
filters: userPoolFilters,
|
||||||
columns: userPoolColumns,
|
columns: userPoolColumns,
|
||||||
|
deleteImpact: [
|
||||||
|
{path: 'metadata.estimatedNumberOfUsers', singular: 'user', plural: 'users'},
|
||||||
|
{path: 'metadata.appClients', singular: 'app client', plural: 'app clients'},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
|
|||||||
import {createCloudAdapterRegistry} from './cloudProxy'
|
import {createCloudAdapterRegistry} from './cloudProxy'
|
||||||
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
||||||
import {isServiceType} from './cloud-spi/serviceCatalog'
|
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.
|
* 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',
|
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()
|
const registry = createCloudAdapterRegistry()
|
||||||
|
|
||||||
function registeredAdapters(): Array<{cloud: CloudProvider; adapter: CloudServiceAdapter}> {
|
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)}()`,
|
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||||
).toBe('function')
|
).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`, () => {
|
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?.objectActions ?? []),
|
||||||
...(adapter.schema().capabilities?.databaseActions ?? []),
|
...(adapter.schema().capabilities?.databaseActions ?? []),
|
||||||
...(adapter.schema().capabilities?.kubernetesActions ?? []),
|
...(adapter.schema().capabilities?.kubernetesActions ?? []),
|
||||||
|
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
||||||
...(adapter.schema().capabilities?.collectionActions ?? []),
|
...(adapter.schema().capabilities?.collectionActions ?? []),
|
||||||
...(adapter.schema().capabilities?.itemActions ?? []),
|
...(adapter.schema().capabilities?.itemActions ?? []),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {awsEksSchema} from '../cloud-spi/eksSchema'
|
|||||||
import {awsKmsSchema} from '../cloud-spi/kmsSchema'
|
import {awsKmsSchema} from '../cloud-spi/kmsSchema'
|
||||||
import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema'
|
import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema'
|
||||||
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
|
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
|
||||||
|
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
|
||||||
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
||||||
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
||||||
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
|
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 () => {
|
test('clears the SES mailbox through the email adapter', async () => {
|
||||||
let cleared = false
|
let cleared = false
|
||||||
const app = appWithRoutes([mockAdapter('aws', {
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
|
|||||||
@@ -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) => {
|
app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => {
|
||||||
const cloud = c.req.param('cloud') as CloudProvider
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
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) => {
|
app.delete('/:cloud/services/:service/resources/:id/items/:itemId', async (c) => {
|
||||||
const target = childTarget(c)
|
const target = childTarget(c)
|
||||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ import type {
|
|||||||
StorageObjectDownload,
|
StorageObjectDownload,
|
||||||
StorageObjectList,
|
StorageObjectList,
|
||||||
UpdateResourceInput,
|
UpdateResourceInput,
|
||||||
|
CreateUserPoolGroupInput,
|
||||||
|
UserPoolGroup,
|
||||||
|
UserPoolSignInTestInput,
|
||||||
|
UserPoolSignInTestResult,
|
||||||
|
UserPoolUserAction,
|
||||||
} from '../cloud-spi/types'
|
} from '../cloud-spi/types'
|
||||||
import type {
|
import type {
|
||||||
ChildCollection,
|
ChildCollection,
|
||||||
@@ -664,6 +669,12 @@ export class CloudProxyService {
|
|||||||
return this.requireItems(cloud, service).listItems(resourceId, page)
|
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> {
|
async putFlatItem(cloud: CloudProvider, service: CloudServiceType, resourceId: string, body: Record<string, unknown>): Promise<ChildItem> {
|
||||||
const items = this.requireItems(cloud, service)
|
const items = this.requireItems(cloud, service)
|
||||||
if (!items.putItem) throw new NotSupportedError(`Writing items is not supported for ${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)
|
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[]> {
|
async listKubernetesNodegroups(cloud: CloudProvider, clusterId: string): Promise<KubernetesNodegroup[]> {
|
||||||
const adapter = this.requireAdapter(cloud, 'k8s')
|
const adapter = this.requireAdapter(cloud, 'k8s')
|
||||||
if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`)
|
if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`)
|
||||||
|
|||||||
Reference in New Issue
Block a user