diff --git a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts index 7a4ce10..4307642 100644 --- a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts @@ -125,9 +125,31 @@ describe('AwsUserPoolsAdapter', () => { mfaConfiguration: 'OFF', deletionProtection: 'INACTIVE', appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}], + lambdaTriggers: [], }) }) + test('lists the pool\'s Lambda triggers in sign-in order, with the trigger version when set', async () => { + const arn = (name: string) => `arn:aws:lambda:us-east-1:000000000000:function:${name}` + const {client} = stubCognito((command) => { + if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, LambdaConfig: { + PreTokenGeneration: arn('claims'), + PreTokenGenerationConfig: {LambdaArn: arn('claims'), LambdaVersion: 'V2_0'}, + PreSignUp: `${arn('signup')}:live`, + CustomEmailSender: {LambdaArn: arn('mailer'), LambdaVersion: 'V1_0'}, + KMSKeyID: 'arn:aws:kms:us-east-1:000000000000:key/1', + }}} + return {} + }) + + const resource = await new AwsUserPoolsAdapter(client).get(POOL_ID) + expect(resource?.metadata.lambdaTriggers).toEqual([ + {trigger: 'PreSignUp', label: 'Pre sign-up', functionArn: `${arn('signup')}:live`, functionName: 'signup', version: null}, + {trigger: 'PreTokenGeneration', label: 'Pre token generation', functionArn: arn('claims'), functionName: 'claims', version: 'V2_0'}, + {trigger: 'CustomEmailSender', label: 'Custom email sender', functionArn: arn('mailer'), functionName: 'mailer', version: 'V1_0'}, + ]) + }) + test('counts users itself, since the runtime leaves EstimatedNumberOfUsers at 0', async () => { const {client} = stubCognito((command) => { if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, EstimatedNumberOfUsers: 0}} diff --git a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts index 3e9e893..e3354d2 100644 --- a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts +++ b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts @@ -460,10 +460,43 @@ function toResource(pool: UserPoolType, clients: UserPoolClientDescription[], us mfaConfiguration: pool.MfaConfiguration ?? null, deletionProtection: pool.DeletionProtection ?? null, appClients: clients.map((client) => ({clientId: client.ClientId ?? '', clientName: client.ClientName ?? ''})), + lambdaTriggers: lambdaTriggersOf(pool.LambdaConfig), }, } } +// Labels as the AWS console names them, in the order a sign-up and sign-in reach them. +const LAMBDA_TRIGGERS = [ + ['PreSignUp', 'Pre sign-up'], + ['UserMigration', 'User migration'], + ['CustomMessage', 'Custom message'], + ['PostConfirmation', 'Post confirmation'], + ['PreAuthentication', 'Pre authentication'], + ['DefineAuthChallenge', 'Define auth challenge'], + ['CreateAuthChallenge', 'Create auth challenge'], + ['VerifyAuthChallengeResponse', 'Verify auth challenge response'], + ['PostAuthentication', 'Post authentication'], + ['PreTokenGeneration', 'Pre token generation'], + ['CustomEmailSender', 'Custom email sender'], + ['CustomSMSSender', 'Custom SMS sender'], +] as const + +type LambdaTrigger = {trigger: string; label: string; functionArn: string; functionName: string; version: string | null} + +function lambdaTriggersOf(config: UserPoolType['LambdaConfig']): LambdaTrigger[] { + if (!config) return [] + return LAMBDA_TRIGGERS.flatMap(([trigger, label]) => { + // PreTokenGenerationConfig carries the trigger version and supersedes the older PreTokenGeneration field. + const versioned = trigger === 'PreTokenGeneration' ? config.PreTokenGenerationConfig + : trigger === 'CustomEmailSender' ? config.CustomEmailSender + : trigger === 'CustomSMSSender' ? config.CustomSMSSender + : undefined + const functionArn = versioned?.LambdaArn ?? config[trigger] + if (typeof functionArn !== 'string' || !functionArn) return [] + return [{trigger, label, functionArn, functionName: functionArn.split(':')[6] ?? functionArn, version: versioned?.LambdaVersion ?? null}] + }) +} + function toUserItem( username: string | undefined, attributes: AttributeType[] | undefined, diff --git a/packages/frontend/src/components/CognitoTriggersSection.tsx b/packages/frontend/src/components/CognitoTriggersSection.tsx new file mode 100644 index 0000000..1a966f8 --- /dev/null +++ b/packages/frontend/src/components/CognitoTriggersSection.tsx @@ -0,0 +1,31 @@ +import {ScrollText, Zap} from 'lucide-react' +import type {CloudResource} from '@/types/resource' + +type LambdaTrigger = {trigger: string; label: string; functionArn: string; functionName: string; version: string | null} + +export function CognitoTriggersSection({pool}: {pool?: CloudResource}) { + const triggers = triggersOf(pool) + return <> +
+

Lambda triggers

Functions Cognito runs during sign-up, sign-in and token issue
+
+ {pool && triggers.length === 0 &&

This pool runs no Lambda triggers.

} + {triggers.length > 0 &&
{triggers.map((trigger) => ( + + + + + + + ))}
TriggerFunctionVersion
{trigger.label}{trigger.functionName}{trigger.version ? {trigger.version} : '—'} + Function + Logs +
} + +} + +function triggersOf(pool?: CloudResource): LambdaTrigger[] { + const triggers = pool?.metadata.lambdaTriggers + if (!Array.isArray(triggers)) return [] + return triggers.filter((trigger): trigger is LambdaTrigger => typeof trigger?.functionName === 'string' && typeof trigger?.label === 'string') +} diff --git a/packages/frontend/src/components/CognitoUsersPanel.test.tsx b/packages/frontend/src/components/CognitoUsersPanel.test.tsx index 6ecbbaf..b70aa6b 100644 --- a/packages/frontend/src/components/CognitoUsersPanel.test.tsx +++ b/packages/frontend/src/components/CognitoUsersPanel.test.tsx @@ -64,6 +64,24 @@ test('shows the app client id and the pool users', async () => { expect(api.getCloudResource).toHaveBeenCalledWith('aws', 'userpools', summary.id, expect.anything()) }) +test('lists the pool\'s Lambda triggers with links to the function and its logs', async () => { + api.getCloudResource.mockResolvedValue({...pool('email'), metadata: {...pool('email').metadata, lambdaTriggers: [ + {trigger: 'PreTokenGeneration', label: 'Pre token generation', functionArn: 'arn:aws:lambda:us-east-1:000000000000:function:notes-demo-pre-token', functionName: 'notes-demo-pre-token', version: null}, + ]}}) + renderPanel() + + expect(await screen.findByText('Pre token generation')).toBeInTheDocument() + expect(screen.getByRole('link', {name: 'Open function notes-demo-pre-token'})).toHaveAttribute('href', '/cloud-explorer/aws/serverless?resource=notes-demo-pre-token') + expect(screen.getByRole('link', {name: 'Open logs for notes-demo-pre-token'})).toHaveAttribute('href', '/cloud-explorer/aws/logs?resource=%2Faws%2Flambda%2Fnotes-demo-pre-token') +}) + +test('says when a pool has no Lambda triggers', async () => { + api.getCloudResource.mockResolvedValue(pool('email')) + renderPanel() + + expect(await screen.findByText('This pool runs no Lambda triggers.')).toBeInTheDocument() +}) + test('creates an email user who can sign in immediately', async () => { const user = userEvent.setup() api.getCloudResource.mockResolvedValue(pool('email')) diff --git a/packages/frontend/src/components/CognitoUsersPanel.tsx b/packages/frontend/src/components/CognitoUsersPanel.tsx index 1ef2c70..7697698 100644 --- a/packages/frontend/src/components/CognitoUsersPanel.tsx +++ b/packages/frontend/src/components/CognitoUsersPanel.tsx @@ -4,6 +4,7 @@ import {useEffect, useState} from 'react' import {deleteFlatItem, getCloudResource, listFlatItems, listUserPoolGroups, putFlatItem} from '@/api/cloudProxyClient' import {CognitoGroupsSection} from '@/components/CognitoGroupsSection' import {CognitoSignInTest} from '@/components/CognitoSignInTest' +import {CognitoTriggersSection} from '@/components/CognitoTriggersSection' import {CognitoUserDetail} from '@/components/CognitoUserDetail' import {CopyValueButton} from '@/components/CopyValueButton' import {useAccountId} from '@/lib/accountStore' @@ -97,6 +98,8 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) { {client.clientName || '—'}{client.clientId} ))}} + +