Merge pull request 'feat(api): add Cognito user pools to the cloud explorer' (#8) from feat/cognito-user-pools into main
CI / checks (push) Failing after 12m33s
CI / checks (push) Failing after 12m33s
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
"@aws-sdk/client-codebuild": "^3.1076.0",
|
||||
"@aws-sdk/client-codedeploy": "^3.1144.0",
|
||||
"@aws-sdk/client-codepipeline": "^3.1076.0",
|
||||
"@aws-sdk/client-cognito-identity-provider": "^3.1146.0",
|
||||
"@aws-sdk/client-dynamodb": "^3.1088.0",
|
||||
"@aws-sdk/client-ec2": "^3.1076.0",
|
||||
"@aws-sdk/client-ecr": "^3.1076.0",
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
AdminCreateUserCommand,
|
||||
AdminDeleteUserCommand,
|
||||
AdminGetUserCommand,
|
||||
AdminSetUserPasswordCommand,
|
||||
CreateUserPoolClientCommand,
|
||||
CreateUserPoolCommand,
|
||||
DeleteUserPoolCommand,
|
||||
DescribeUserPoolCommand,
|
||||
ListUserPoolClientsCommand,
|
||||
ListUserPoolsCommand,
|
||||
ListUsersCommand,
|
||||
type CognitoIdentityProviderClient,
|
||||
} from '@aws-sdk/client-cognito-identity-provider'
|
||||
import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
type SendResult = Record<string, unknown>
|
||||
|
||||
function stubCognito(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||
const sent: object[] = []
|
||||
const client = {
|
||||
async send(command: object) {
|
||||
sent.push(command)
|
||||
return handler(command)
|
||||
},
|
||||
} as unknown as CognitoIdentityProviderClient
|
||||
return {client, sent}
|
||||
}
|
||||
|
||||
function awsError(name: string): Error {
|
||||
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: 400}})
|
||||
}
|
||||
|
||||
const POOL_ID = 'us-east-1_8e65d229a'
|
||||
const CREATED = new Date('2026-10-23T16:01:22.000Z')
|
||||
|
||||
const pool = {
|
||||
Id: POOL_ID,
|
||||
Name: 'orders-users',
|
||||
Arn: `arn:aws:cognito-idp:us-east-1:000000000000:userpool/${POOL_ID}`,
|
||||
Status: 'Enabled',
|
||||
CreationDate: CREATED,
|
||||
UsernameAttributes: ['email'],
|
||||
EstimatedNumberOfUsers: 1,
|
||||
MfaConfiguration: 'OFF',
|
||||
DeletionProtection: 'INACTIVE',
|
||||
Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: true, RequireLowercase: true, RequireNumbers: true, RequireSymbols: true}},
|
||||
}
|
||||
|
||||
const alice = {
|
||||
Username: 'aa36ecc1-ed8a-43b5-84e9-988398c17436',
|
||||
Attributes: [
|
||||
{Name: 'sub', Value: 'aa36ecc1-ed8a-43b5-84e9-988398c17436'},
|
||||
{Name: 'email', Value: 'alice@example.com'},
|
||||
{Name: 'email_verified', Value: 'true'},
|
||||
],
|
||||
UserCreateDate: CREATED,
|
||||
UserLastModifiedDate: CREATED,
|
||||
Enabled: true,
|
||||
UserStatus: 'CONFIRMED',
|
||||
}
|
||||
|
||||
function runtimeStub() {
|
||||
return stubCognito((command) => {
|
||||
if (command instanceof CreateUserPoolCommand) return {UserPool: pool}
|
||||
if (command instanceof DescribeUserPoolCommand) return {UserPool: pool}
|
||||
if (command instanceof ListUserPoolClientsCommand) return {UserPoolClients: [{ClientId: 'a8e5c7d38d3b4a1a97c9d822de', ClientName: 'orders-users-client', UserPoolId: POOL_ID}]}
|
||||
if (command instanceof AdminCreateUserCommand) return {User: {...alice, UserStatus: 'FORCE_CHANGE_PASSWORD'}}
|
||||
if (command instanceof AdminGetUserCommand) return {...alice, UserAttributes: alice.Attributes}
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
describe('AwsUserPoolsAdapter', () => {
|
||||
test('lists every page of user pools and filters by name or id', async () => {
|
||||
const {client, sent} = stubCognito((command) => {
|
||||
if (!(command instanceof ListUserPoolsCommand)) return {}
|
||||
return command.input.NextToken
|
||||
? {UserPools: [{Id: 'us-east-1_second', Name: 'billing', Status: 'Enabled', CreationDate: CREATED}]}
|
||||
: {UserPools: [{Id: POOL_ID, Name: 'orders-users', Status: 'Enabled', CreationDate: CREATED}], NextToken: 'page-2'}
|
||||
})
|
||||
const adapter = new AwsUserPoolsAdapter(client)
|
||||
|
||||
expect((await adapter.list()).map((resource) => resource.id)).toEqual([POOL_ID, 'us-east-1_second'])
|
||||
expect(sent.filter((command) => command instanceof ListUserPoolsCommand)).toHaveLength(2)
|
||||
expect((await adapter.list({search: 'BILL'})).map((resource) => resource.name)).toEqual(['billing'])
|
||||
expect((await adapter.list())[0]).toMatchObject({type: 'user-pool', region: 'us-east-1', status: 'Enabled', createdAt: CREATED.toISOString()})
|
||||
})
|
||||
|
||||
test('lists pools with the details the inspector shows', async () => {
|
||||
const {client} = stubCognito((command) => {
|
||||
if (command instanceof ListUserPoolsCommand) return {UserPools: [{Id: POOL_ID, Name: 'orders-users', Status: 'Enabled', CreationDate: CREATED}]}
|
||||
if (command instanceof DescribeUserPoolCommand) return {UserPool: pool}
|
||||
if (command instanceof ListUserPoolClientsCommand) return {UserPoolClients: [{ClientId: 'a8e5c7d38d3b4a1a97c9d822de', ClientName: 'orders-users-client'}]}
|
||||
return {}
|
||||
})
|
||||
|
||||
const [listed] = await new AwsUserPoolsAdapter(client).list()
|
||||
expect(listed.metadata).toMatchObject({signInWith: 'email', appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}]})
|
||||
})
|
||||
|
||||
test('describes a pool with its sign-in method, password policy and app clients', async () => {
|
||||
const resource = await new AwsUserPoolsAdapter(runtimeStub().client).get(POOL_ID)
|
||||
|
||||
expect(resource?.metadata).toEqual({
|
||||
arn: pool.Arn,
|
||||
signInWith: 'email',
|
||||
passwordPolicy: '8+ characters with uppercase, lowercase, number, symbol',
|
||||
estimatedNumberOfUsers: 1,
|
||||
mfaConfiguration: 'OFF',
|
||||
deletionProtection: 'INACTIVE',
|
||||
appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}],
|
||||
})
|
||||
})
|
||||
|
||||
test('summarises a relaxed password policy without character rules', async () => {
|
||||
const {client} = stubCognito((command) => {
|
||||
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}}}
|
||||
return {}
|
||||
})
|
||||
|
||||
expect((await new AwsUserPoolsAdapter(client).get(POOL_ID))?.metadata.passwordPolicy).toBe('8+ characters')
|
||||
})
|
||||
|
||||
test('returns null for a pool the runtime does not know', async () => {
|
||||
const {client} = stubCognito(() => { throw awsError('ResourceNotFoundException') })
|
||||
|
||||
expect(await new AwsUserPoolsAdapter(client).get('us-east-1_missing')).toBeNull()
|
||||
})
|
||||
|
||||
test('creates an email pool with a public app client by default', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
await new AwsUserPoolsAdapter(client).create({values: {name: 'orders-users'}})
|
||||
|
||||
const createPool = sent.find((command) => command instanceof CreateUserPoolCommand) as CreateUserPoolCommand
|
||||
expect(createPool.input).toEqual({PoolName: 'orders-users', UsernameAttributes: ['email'], AutoVerifiedAttributes: ['email']})
|
||||
const createClient = sent.find((command) => command instanceof CreateUserPoolClientCommand) as CreateUserPoolClientCommand
|
||||
expect(createClient.input).toEqual({
|
||||
UserPoolId: POOL_ID,
|
||||
ClientName: 'orders-users-client',
|
||||
GenerateSecret: false,
|
||||
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
||||
})
|
||||
})
|
||||
|
||||
test('creates a username pool with a relaxed password policy and no app client', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
await new AwsUserPoolsAdapter(client).create({values: {name: 'testers', signInWith: 'username', passwordPolicy: 'relaxed', createAppClient: 'no'}})
|
||||
|
||||
const createPool = sent.find((command) => command instanceof CreateUserPoolCommand) as CreateUserPoolCommand
|
||||
expect(createPool.input).toEqual({
|
||||
PoolName: 'testers',
|
||||
Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}},
|
||||
})
|
||||
expect(sent.some((command) => command instanceof CreateUserPoolClientCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('removes the new pool when its app client cannot be created', async () => {
|
||||
const {client, sent} = stubCognito((command) => {
|
||||
if (command instanceof CreateUserPoolCommand) return {UserPool: pool}
|
||||
if (command instanceof CreateUserPoolClientCommand) throw awsError('InvalidParameterException')
|
||||
return {}
|
||||
})
|
||||
|
||||
await expect(new AwsUserPoolsAdapter(client).create({values: {name: 'orders-users'}})).rejects.toThrow('InvalidParameterException')
|
||||
expect((sent.at(-1) as DeleteUserPoolCommand).input).toEqual({UserPoolId: POOL_ID})
|
||||
})
|
||||
|
||||
test('rejects invalid create input before calling the runtime', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
const adapter = new AwsUserPoolsAdapter(client)
|
||||
|
||||
await expect(adapter.create({values: {}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {name: 'bad/name'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {name: 'ok', signInWith: 'phone'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('deletes a pool by id', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
await new AwsUserPoolsAdapter(client).delete(POOL_ID)
|
||||
|
||||
expect(sent[0]).toBeInstanceOf(DeleteUserPoolCommand)
|
||||
expect((sent[0] as DeleteUserPoolCommand).input).toEqual({UserPoolId: POOL_ID})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsUserPoolsAdapter users', () => {
|
||||
test('lists users as items keyed by username with their email and status', async () => {
|
||||
const {client, sent} = stubCognito(() => ({Users: [alice], PaginationToken: 'next-page'}))
|
||||
const page = await new AwsUserPoolsAdapter(client).items.listItems(POOL_ID, {limit: 500, cursor: 'this-page'})
|
||||
|
||||
expect((sent[0] as ListUsersCommand).input).toEqual({UserPoolId: POOL_ID, Limit: 60, PaginationToken: 'this-page'})
|
||||
expect(page.nextCursor).toBe('next-page')
|
||||
expect(page.items).toEqual([{
|
||||
id: alice.Username,
|
||||
collectionId: null,
|
||||
timestamp: CREATED.toISOString(),
|
||||
body: {
|
||||
username: alice.Username,
|
||||
email: 'alice@example.com',
|
||||
emailVerified: true,
|
||||
status: 'CONFIRMED',
|
||||
enabled: true,
|
||||
lastModifiedAt: CREATED.toISOString(),
|
||||
attributes: {sub: alice.Username, email: 'alice@example.com', email_verified: 'true'},
|
||||
},
|
||||
metadata: {},
|
||||
}])
|
||||
})
|
||||
|
||||
test('creates a user whose password is permanent so it can sign in at once', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
const user = await new AwsUserPoolsAdapter(client).items.putItem!(POOL_ID, {email: 'alice@example.com', password: ' Passw0rd! '})
|
||||
|
||||
expect((sent[0] as AdminCreateUserCommand).input).toEqual({
|
||||
UserPoolId: POOL_ID,
|
||||
Username: 'alice@example.com',
|
||||
UserAttributes: [{Name: 'email', Value: 'alice@example.com'}, {Name: 'email_verified', Value: 'true'}],
|
||||
TemporaryPassword: ' Passw0rd! ',
|
||||
MessageAction: 'SUPPRESS',
|
||||
})
|
||||
expect((sent[1] as AdminSetUserPasswordCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username, Password: ' Passw0rd! ', Permanent: true})
|
||||
expect(user.body.status).toBe('CONFIRMED')
|
||||
})
|
||||
|
||||
test('leaves the password temporary when asked', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
await new AwsUserPoolsAdapter(client).items.putItem!(POOL_ID, {username: 'bob', password: 'Passw0rd!', permanent: false})
|
||||
|
||||
expect((sent[0] as AdminCreateUserCommand).input.Username).toBe('bob')
|
||||
expect((sent[0] as AdminCreateUserCommand).input.UserAttributes).toEqual([])
|
||||
expect(sent.some((command) => command instanceof AdminSetUserPasswordCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects a user without a name, password or valid email', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
const users = new AwsUserPoolsAdapter(client).items
|
||||
|
||||
await expect(users.putItem!(POOL_ID, {password: 'Passw0rd!'})).rejects.toThrow('Provide a username or an email address')
|
||||
await expect(users.putItem!(POOL_ID, {email: 'alice@example.com'})).rejects.toThrow('password is required')
|
||||
await expect(users.putItem!(POOL_ID, {email: 'not-an-email', password: 'Passw0rd!'})).rejects.toThrow('email must be a valid email address')
|
||||
await expect(users.putItem!(POOL_ID, {username: 'bob', password: 'Passw0rd!', permanent: 'yes'})).rejects.toThrow('permanent must be true or false')
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('returns null for a missing user and deletes by username', async () => {
|
||||
const missing = stubCognito(() => { throw awsError('UserNotFoundException') })
|
||||
expect(await new AwsUserPoolsAdapter(missing.client).items.getItem!(POOL_ID, 'nobody')).toBeNull()
|
||||
|
||||
const {client, sent} = runtimeStub()
|
||||
await new AwsUserPoolsAdapter(client).items.deleteItem!(POOL_ID, alice.Username)
|
||||
expect((sent[0] as AdminDeleteUserCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
AdminCreateUserCommand,
|
||||
AdminDeleteUserCommand,
|
||||
AdminGetUserCommand,
|
||||
AdminSetUserPasswordCommand,
|
||||
CreateUserPoolClientCommand,
|
||||
CreateUserPoolCommand,
|
||||
DeleteUserPoolCommand,
|
||||
DescribeUserPoolCommand,
|
||||
ListUserPoolClientsCommand,
|
||||
ListUserPoolsCommand,
|
||||
ListUsersCommand,
|
||||
type AttributeType,
|
||||
type CognitoIdentityProviderClient,
|
||||
type PasswordPolicyType,
|
||||
type UserPoolClientDescription,
|
||||
type UserPoolDescriptionType,
|
||||
type UserPoolType,
|
||||
type UserType,
|
||||
} from '@aws-sdk/client-cognito-identity-provider'
|
||||
import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections'
|
||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {
|
||||
USER_POOL_NAME_PATTERN,
|
||||
USER_POOL_PASSWORD_POLICIES,
|
||||
USER_POOL_SIGN_IN,
|
||||
awsUserPoolsSchema,
|
||||
} from '../cloud-spi/userPoolsSchema'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
/** Cognito caps every list page at 60. */
|
||||
const COGNITO_PAGE_LIMIT = 60
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
/**
|
||||
* Talks to Cognito user pools — verified against Floci core (community, `latest`).
|
||||
*
|
||||
* Notes from probing the runtime:
|
||||
* - A missing pool is `ResourceNotFoundException` and a missing user is
|
||||
* `UserNotFoundException`, both with HTTP **400**; `get` matches on the name.
|
||||
* - With email sign-in the stored `Username` is the generated `sub`, not the
|
||||
* address, so users are keyed by `Username` and the email is read from the
|
||||
* attributes. Admin calls accept either form.
|
||||
* - An omitted password policy defaults to requiring upper, lower, number and
|
||||
* symbol, matching AWS.
|
||||
* - `InitiateAuth` with `USER_PASSWORD_AUTH` issues real tokens once a user's
|
||||
* password is permanent, so users created here can sign in immediately.
|
||||
*
|
||||
* Users are the pool's child items. The app client is created with the pool, as
|
||||
* the console's create flow does, and its id is surfaced in the pool metadata.
|
||||
*/
|
||||
export class AwsUserPoolsAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'userpools' as const
|
||||
readonly items: ItemStoreAdapter
|
||||
|
||||
constructor(private readonly cognito: CognitoIdentityProviderClient) {
|
||||
this.items = new UserStore(cognito)
|
||||
}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsUserPoolsSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const pools: UserPoolDescriptionType[] = []
|
||||
let nextToken: string | undefined
|
||||
|
||||
do {
|
||||
const res = await this.cognito.send(new ListUserPoolsCommand({MaxResults: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
|
||||
pools.push(...(res.UserPools ?? []))
|
||||
nextToken = res.NextToken || undefined
|
||||
} while (nextToken)
|
||||
|
||||
// ListUserPools omits the sign-in method, password policy and app clients the
|
||||
// inspector shows, so each match is described. Pools are few on a local runtime.
|
||||
const matches = filterBySearch(pools.flatMap(toSummaryResource), query.search)
|
||||
return Promise.all(matches.map(async (summary) => (await this.get(summary.id)) ?? summary))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
try {
|
||||
const [pool, clients] = await Promise.all([
|
||||
this.cognito.send(new DescribeUserPoolCommand({UserPoolId: id})),
|
||||
this.listClients(id),
|
||||
])
|
||||
return pool.UserPool ? toResource(pool.UserPool, clients) : null
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const name = requiredString(input.values.name, 'name')
|
||||
if (!new RegExp(USER_POOL_NAME_PATTERN).test(name)) {
|
||||
throw new ValidationError('name must be up to 128 letters, digits, spaces or + = , . @ _ -')
|
||||
}
|
||||
const signInWith = optionalOneOf(input.values.signInWith, USER_POOL_SIGN_IN, 'signInWith') ?? 'email'
|
||||
const passwordPolicy = optionalOneOf(input.values.passwordPolicy, USER_POOL_PASSWORD_POLICIES, 'passwordPolicy') ?? 'default'
|
||||
const createAppClient = optionalOneOf(input.values.createAppClient, ['yes', 'no'] as const, 'createAppClient') ?? 'yes'
|
||||
|
||||
const created = await this.cognito.send(new CreateUserPoolCommand({
|
||||
PoolName: name,
|
||||
...(signInWith === 'email' ? {UsernameAttributes: ['email'], AutoVerifiedAttributes: ['email']} : {}),
|
||||
...(passwordPolicy === 'relaxed'
|
||||
? {Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}}
|
||||
: {}),
|
||||
}))
|
||||
const id = created.UserPool?.Id
|
||||
if (!id) throw new RuntimeError(`Cognito did not return the created user pool ${name}`)
|
||||
|
||||
if (createAppClient === 'yes') {
|
||||
try {
|
||||
await this.cognito.send(new CreateUserPoolClientCommand({
|
||||
UserPoolId: id,
|
||||
ClientName: `${name}-client`,
|
||||
GenerateSecret: false,
|
||||
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
||||
}))
|
||||
} catch (error) {
|
||||
// Without its client the pool is not what was asked for; do not leave it half-made.
|
||||
await this.cognito.send(new DeleteUserPoolCommand({UserPoolId: id})).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const pool = await this.get(id)
|
||||
if (!pool) throw new RuntimeError(`Cognito did not return the created user pool ${id}`)
|
||||
return pool
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.cognito.send(new DeleteUserPoolCommand({UserPoolId: id}))
|
||||
}
|
||||
|
||||
private async listClients(poolId: string): Promise<UserPoolClientDescription[]> {
|
||||
const clients: UserPoolClientDescription[] = []
|
||||
let nextToken: string | undefined
|
||||
|
||||
do {
|
||||
const res = await this.cognito.send(new ListUserPoolClientsCommand({UserPoolId: poolId, MaxResults: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
|
||||
clients.push(...(res.UserPoolClients ?? []))
|
||||
nextToken = res.NextToken || undefined
|
||||
} while (nextToken)
|
||||
|
||||
return clients
|
||||
}
|
||||
}
|
||||
|
||||
class UserStore implements ItemStoreAdapter {
|
||||
constructor(private readonly cognito: CognitoIdentityProviderClient) {}
|
||||
|
||||
async listItems(poolId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
|
||||
const res = await this.cognito.send(new ListUsersCommand({
|
||||
UserPoolId: poolId,
|
||||
Limit: Math.min(page.limit ?? COGNITO_PAGE_LIMIT, COGNITO_PAGE_LIMIT),
|
||||
...(page.cursor ? {PaginationToken: page.cursor} : {}),
|
||||
}))
|
||||
return {items: (res.Users ?? []).flatMap((user) => toUserItem(user.Username, user.Attributes, user)), nextCursor: res.PaginationToken || null}
|
||||
}
|
||||
|
||||
async getItem(poolId: string, username: string): Promise<ChildItem | null> {
|
||||
try {
|
||||
const user = await this.cognito.send(new AdminGetUserCommand({UserPoolId: poolId, Username: username}))
|
||||
return toUserItem(user.Username, user.UserAttributes, user)[0] ?? null
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a user that can sign in straight away: the password is set as
|
||||
* permanent unless `permanent` is false, in which case Cognito will demand a
|
||||
* new one at first sign-in (`FORCE_CHANGE_PASSWORD`).
|
||||
*/
|
||||
async putItem(poolId: string, body: Record<string, unknown>): Promise<ChildItem> {
|
||||
const email = optionalString(body.email, 'email')
|
||||
if (email && !EMAIL_PATTERN.test(email)) throw new ValidationError('email must be a valid email address')
|
||||
const username = optionalString(body.username, 'username') ?? email
|
||||
if (!username) throw new ValidationError('Provide a username or an email address')
|
||||
// Not trimmed: leading or trailing spaces are part of a password.
|
||||
const password = body.password
|
||||
if (typeof password !== 'string' || !password) throw new ValidationError('password is required')
|
||||
if (body.permanent !== undefined && typeof body.permanent !== 'boolean') throw new ValidationError('permanent must be true or false')
|
||||
|
||||
const created = await this.cognito.send(new AdminCreateUserCommand({
|
||||
UserPoolId: poolId,
|
||||
Username: username,
|
||||
UserAttributes: email ? [{Name: 'email', Value: email}, {Name: 'email_verified', Value: 'true'}] : [],
|
||||
TemporaryPassword: password,
|
||||
MessageAction: 'SUPPRESS',
|
||||
}))
|
||||
const storedUsername = created.User?.Username
|
||||
if (!storedUsername) throw new RuntimeError(`Cognito did not return the user created in ${poolId}`)
|
||||
|
||||
if (body.permanent !== false) {
|
||||
await this.cognito.send(new AdminSetUserPasswordCommand({UserPoolId: poolId, Username: storedUsername, Password: password, Permanent: true}))
|
||||
}
|
||||
|
||||
const user = await this.getItem(poolId, storedUsername)
|
||||
if (!user) throw new RuntimeError(`Cognito did not return the user ${storedUsername} in ${poolId}`)
|
||||
return user
|
||||
}
|
||||
|
||||
async deleteItem(poolId: string, username: string): Promise<void> {
|
||||
await this.cognito.send(new AdminDeleteUserCommand({UserPoolId: poolId, Username: username}))
|
||||
}
|
||||
}
|
||||
|
||||
function toSummaryResource(pool: UserPoolDescriptionType): CloudResource[] {
|
||||
if (!pool.Id) return []
|
||||
return [{
|
||||
id: pool.Id,
|
||||
name: pool.Name ?? pool.Id,
|
||||
cloud: 'aws',
|
||||
service: 'userpools',
|
||||
type: 'user-pool',
|
||||
region: regionFromPoolId(pool.Id),
|
||||
createdAt: pool.CreationDate ? pool.CreationDate.toISOString() : null,
|
||||
status: pool.Status ?? null,
|
||||
metadata: {},
|
||||
}]
|
||||
}
|
||||
|
||||
function toResource(pool: UserPoolType, clients: UserPoolClientDescription[]): CloudResource {
|
||||
const id = pool.Id ?? ''
|
||||
const policy = pool.Policies?.PasswordPolicy
|
||||
return {
|
||||
id,
|
||||
name: pool.Name ?? id,
|
||||
cloud: 'aws',
|
||||
service: 'userpools',
|
||||
type: 'user-pool',
|
||||
region: regionFromPoolId(id),
|
||||
createdAt: pool.CreationDate ? pool.CreationDate.toISOString() : null,
|
||||
status: pool.Status ?? null,
|
||||
metadata: {
|
||||
arn: pool.Arn,
|
||||
signInWith: pool.UsernameAttributes?.includes('email') ? 'email' : 'username',
|
||||
passwordPolicy: policy ? describePasswordPolicy(policy) : null,
|
||||
estimatedNumberOfUsers: pool.EstimatedNumberOfUsers ?? null,
|
||||
mfaConfiguration: pool.MfaConfiguration ?? null,
|
||||
deletionProtection: pool.DeletionProtection ?? null,
|
||||
appClients: clients.map((client) => ({clientId: client.ClientId ?? '', clientName: client.ClientName ?? ''})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toUserItem(
|
||||
username: string | undefined,
|
||||
attributes: AttributeType[] | undefined,
|
||||
user: Pick<UserType, 'Enabled' | 'UserStatus' | 'UserCreateDate' | 'UserLastModifiedDate'>,
|
||||
): ChildItem[] {
|
||||
if (!username) return []
|
||||
const attributeMap = Object.fromEntries((attributes ?? []).flatMap((attribute) => attribute.Name ? [[attribute.Name, attribute.Value ?? '']] : []))
|
||||
return [{
|
||||
id: username,
|
||||
collectionId: null,
|
||||
timestamp: user.UserCreateDate ? user.UserCreateDate.toISOString() : null,
|
||||
body: {
|
||||
username,
|
||||
email: attributeMap.email ?? null,
|
||||
emailVerified: attributeMap.email_verified === 'true',
|
||||
status: user.UserStatus ?? null,
|
||||
enabled: user.Enabled ?? true,
|
||||
lastModifiedAt: user.UserLastModifiedDate ? user.UserLastModifiedDate.toISOString() : null,
|
||||
attributes: attributeMap,
|
||||
},
|
||||
metadata: {},
|
||||
}]
|
||||
}
|
||||
|
||||
/** One readable line for the inspector, e.g. "8+ characters with uppercase, number". */
|
||||
function describePasswordPolicy(policy: PasswordPolicyType): string {
|
||||
const required = [
|
||||
policy.RequireUppercase && 'uppercase',
|
||||
policy.RequireLowercase && 'lowercase',
|
||||
policy.RequireNumbers && 'number',
|
||||
policy.RequireSymbols && 'symbol',
|
||||
].filter((rule): rule is string => Boolean(rule))
|
||||
const length = `${policy.MinimumLength ?? 8}+ characters`
|
||||
return required.length ? `${length} with ${required.join(', ')}` : length
|
||||
}
|
||||
|
||||
/** Pool ids are `<region>_<suffix>`. */
|
||||
function regionFromPoolId(id: string): string | null {
|
||||
const separator = id.indexOf('_')
|
||||
return separator > 0 ? id.slice(0, separator) : null
|
||||
}
|
||||
|
||||
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
|
||||
const normalized = search?.trim().toLowerCase()
|
||||
if (!normalized) return resources
|
||||
|
||||
return resources.filter(
|
||||
(resource) =>
|
||||
resource.name.toLowerCase().includes(normalized) || resource.id.toLowerCase().includes(normalized),
|
||||
)
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
const name = (error as {name?: string}).name
|
||||
return name === 'ResourceNotFoundException' || name === 'UserNotFoundException'
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, field: string): string {
|
||||
const trimmed = optionalString(value, field)
|
||||
if (!trimmed) throw new ValidationError(`${field} is required`)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, field: string): string | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`)
|
||||
return value.trim() || undefined
|
||||
}
|
||||
|
||||
function optionalOneOf<T extends string>(value: unknown, allowed: readonly T[], field: string): T | undefined {
|
||||
const text = optionalString(value, field)
|
||||
if (text === undefined) return undefined
|
||||
if (!(allowed as readonly string[]).includes(text)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
|
||||
return text as T
|
||||
}
|
||||
@@ -127,3 +127,22 @@ describe('logs errors carry HTTP 400 and must not fall through to it', () => {
|
||||
expect(mapped?.status).toBe(409)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Cognito reports every failure with HTTP 400, so these names must be mapped
|
||||
* explicitly or a missing user would read as a validation error.
|
||||
*/
|
||||
describe('cognito errors carry HTTP 400 and keep their meaning', () => {
|
||||
const cases: Array<[string, CloudErrorStatus]> = [
|
||||
['UserNotFoundException', 404],
|
||||
['UsernameExistsException', 409],
|
||||
['AliasExistsException', 409],
|
||||
['InvalidPasswordException', 400],
|
||||
]
|
||||
|
||||
for (const [name, status] of cases) {
|
||||
test(`${name} maps to ${status}`, () => {
|
||||
expect(mapAwsSdkError(sdkError(name, {$metadata: {httpStatusCode: 400}}))?.status).toBe(status)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -58,6 +58,8 @@ const CONFLICT_NAMES = new Set([
|
||||
'ConcurrentModificationException',
|
||||
'IncorrectState',
|
||||
'DeleteConflictException',
|
||||
'UsernameExistsException',
|
||||
'AliasExistsException',
|
||||
])
|
||||
|
||||
const ACCESS_DENIED_NAMES = new Set([
|
||||
@@ -90,6 +92,7 @@ const VALIDATION_NAMES = new Set([
|
||||
'InvalidBucketName',
|
||||
'InvalidArgumentException',
|
||||
'InvalidParameterException',
|
||||
'InvalidPasswordException',
|
||||
])
|
||||
|
||||
const NOT_FOUND_NAMES = new Set([
|
||||
@@ -119,6 +122,7 @@ const NOT_FOUND_NAMES = new Set([
|
||||
'InvalidSubnetID.NotFound',
|
||||
'InvalidGroupId.NotFound',
|
||||
'InvalidAMIID.NotFound',
|
||||
'UserNotFoundException',
|
||||
])
|
||||
|
||||
const NOT_IMPLEMENTED_NAMES = new Set([
|
||||
|
||||
@@ -30,6 +30,7 @@ import { SESv2Client } from "@aws-sdk/client-sesv2";
|
||||
import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
|
||||
import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
|
||||
import { LightsailClient } from "@aws-sdk/client-lightsail";
|
||||
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
|
||||
|
||||
const endpoint = process.env.FLOCI_ENDPOINT;
|
||||
const region = process.env.AWS_REGION || "us-east-1";
|
||||
@@ -91,6 +92,7 @@ export type AwsClients = {
|
||||
cloudwatch: CloudWatchClient;
|
||||
elasticache: ElastiCacheClient;
|
||||
lightsail: LightsailClient;
|
||||
cognito: CognitoIdentityProviderClient;
|
||||
};
|
||||
|
||||
export type AwsClientName = keyof AwsClients;
|
||||
@@ -136,6 +138,7 @@ function buildClients(accountId: string): AwsClients {
|
||||
cloudwatch: new CloudWatchClient(base),
|
||||
elasticache: new ElastiCacheClient(base),
|
||||
lightsail: new LightsailClient(base),
|
||||
cognito: new CognitoIdentityProviderClient(base),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -239,6 +239,13 @@ export const SERVICE_CATALOG = {
|
||||
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
|
||||
routeByCloud: {aws: '/secretsmanager'},
|
||||
},
|
||||
userpools: {
|
||||
displayName: 'User Directories',
|
||||
displayNameByCloud: {aws: 'Cognito'},
|
||||
iconKey: 'userpools',
|
||||
group: 'Security',
|
||||
order: 7,
|
||||
},
|
||||
iac: {
|
||||
displayName: 'Infrastructure as Code',
|
||||
displayNameByCloud: {aws: 'CloudFormation'},
|
||||
|
||||
@@ -230,7 +230,7 @@ export type KnownResourceType =
|
||||
| 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function'
|
||||
| 'secret' | 'iam-user' | 'servicebus-namespace' | 'queue' | 'fifo-queue'
|
||||
| 'topic' | 'event-bus' | 'rest-api' | 'stack' | 'email' | 'sql-server'
|
||||
| 'postgres-flexible-server' | 'load-balancer' | 'state-machine' | 'scheduler-job' | 'key' | 'parameter' | 'cloud-run-service' | 'log-group'
|
||||
| 'postgres-flexible-server' | 'load-balancer' | 'state-machine' | 'scheduler-job' | 'key' | 'parameter' | 'cloud-run-service' | 'log-group' | 'user-pool'
|
||||
|
||||
export interface CloudResource {
|
||||
id: string
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
/** Cognito's own limit and character set for user pool names. */
|
||||
export const USER_POOL_NAME_PATTERN = '^[\\w\\s+=,.@-]{1,128}$'
|
||||
export const USER_POOL_SIGN_IN = ['email', 'username'] as const
|
||||
export const USER_POOL_PASSWORD_POLICIES = ['default', 'relaxed'] as const
|
||||
|
||||
const userPoolColumns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Name'},
|
||||
{name: 'id', label: 'Pool ID', format: 'code'},
|
||||
{name: 'status', label: 'Status', format: 'badge'},
|
||||
{name: 'createdAt', label: 'Created', format: 'datetime'},
|
||||
]
|
||||
|
||||
const userPoolFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
export function awsUserPoolsSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'userpools',
|
||||
displayName: 'Amazon Cognito user pools',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Pool name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
validation: {pattern: USER_POOL_NAME_PATTERN, message: 'Use up to 128 letters, digits, spaces or + = , . @ _ -'},
|
||||
},
|
||||
{
|
||||
name: 'signInWith',
|
||||
label: 'Sign in with',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'email',
|
||||
description: 'Email makes the address the sign-in name; username keeps a separate user name.',
|
||||
options: [{label: 'Email address', value: 'email'}, {label: 'Username', value: 'username'}],
|
||||
},
|
||||
{
|
||||
name: 'passwordPolicy',
|
||||
label: 'Password policy',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'default',
|
||||
description: 'Relaxed only requires 8 characters, which keeps local test users quick to create.',
|
||||
options: [
|
||||
{label: 'AWS default (upper, lower, number, symbol)', value: 'default'},
|
||||
{label: 'Relaxed (8+ characters)', value: 'relaxed'},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'createAppClient',
|
||||
label: 'App client',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'yes',
|
||||
description: 'A public client (no secret) with username/password and refresh-token sign-in, named <pool>-client.',
|
||||
options: [{label: 'Create an app client', value: 'yes'}, {label: 'No app client', value: 'no'}],
|
||||
},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {
|
||||
resourceActions: [
|
||||
{name: 'list', label: 'List user pools', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create user pool', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete user pool', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect user pool', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
itemActions: [
|
||||
{name: 'list', label: 'List users', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'get', label: 'Get user', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'put', label: 'Create user', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete user', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: userPoolFilters,
|
||||
columns: userPoolColumns,
|
||||
}
|
||||
}
|
||||
|
||||
export function userPoolsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||
return cloud === 'aws' ? awsUserPoolsSchema() : null
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter'
|
||||
import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
|
||||
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
|
||||
import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
|
||||
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
|
||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||
import {createEc2Service} from './services/ec2'
|
||||
import {createEksService} from './services/eks'
|
||||
@@ -96,6 +97,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
||||
new AwsKinesisAdapter(clients.kinesis),
|
||||
new AwsElastiCacheAdapter(clients.elasticache),
|
||||
new AwsLightsailAdapter(clients.lightsail),
|
||||
new AwsUserPoolsAdapter(clients.cognito),
|
||||
new AzureStorageAdapter(),
|
||||
new AzureServiceBusAdapter(),
|
||||
new AzureDatabaseAdapter(),
|
||||
|
||||
@@ -25,6 +25,7 @@ const AWS_CONSOLE_SERVICES: Record<string, CloudServiceType[]> = {
|
||||
codebuild: ['codebuild'],
|
||||
codedeploy: ['codedeploy'],
|
||||
codepipeline: ['codepipeline'],
|
||||
'cognito-idp': ['userpools'],
|
||||
dynamodb: ['nosql'],
|
||||
ec2: ['compute', 'networking'],
|
||||
ecr: ['ecr'],
|
||||
|
||||
Reference in New Issue
Block a user