diff --git a/docs/packages/amplify-cli/src/commands/gen2-migration/generate.md b/docs/packages/amplify-cli/src/commands/gen2-migration/generate.md index 4390bede5ca..ae6fabb9e58 100644 --- a/docs/packages/amplify-cli/src/commands/gen2-migration/generate.md +++ b/docs/packages/amplify-cli/src/commands/gen2-migration/generate.md @@ -160,6 +160,16 @@ execution. Runs last and writes `backend.ts` from accumulated content. - **Renderers are pure.** No AWS calls, no file I/O, no `Gen1App` dependency. Pass options, get AST nodes. +### AdminQueries REST APIs + +The REST API generator recognizes the CLI-owned AdminQueries feature only when +the API resource is named `AdminQueries` and its `amplify-meta.json` entry has +the expected Cognito and Lambda dependencies. Function-name prefixes alone do +not enable AdminQueries authorization or IAM permissions. Generated Gen1 User +Pool ARNs use CDK's partition-aware `Stack.formatArn`. If the referenced Gen1 +User Pool ID is missing, generation warns and scopes the Lambda policy to the +Gen2 User Pool only. + ## Execution Flow ```mermaid diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/rest-api/rest-api.generator.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/rest-api/rest-api.generator.test.ts index cfa2ed9b74f..2a8a5fe8079 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/rest-api/rest-api.generator.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/rest-api/rest-api.generator.test.ts @@ -1073,4 +1073,440 @@ describe('RestApiGenerator', () => { const output = writtenFile('resource.ts'); expect(output).toContain('myapiApi'); }); + + it('renders Cognito authorizer and admin permissions for AdminQueries API', async () => { + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + api: { + AdminQueries: { + service: 'API Gateway', + output: { RootUrl: 'https://abc123.execute-api.us-east-1.amazonaws.com/main', ApiId: 'abc123' }, + dependsOn: [ + { category: 'auth', resourceName: 'myAuth', attributes: ['UserPoolId'] }, + { category: 'function', resourceName: 'AdminQueriesd29134db', attributes: ['Arn', 'Name'] }, + ], + }, + }, + auth: { myAuth: { service: 'Cognito', output: { UserPoolId: 'us-east-1_abc123' } } }, + }); + jest.spyOn(gen1App, 'cliInputs').mockReturnValue({ + paths: { '/{proxy+}': { lambdaFunction: 'AdminQueriesd29134db', permissions: { setting: 'private' } } }, + }); + jest.spyOn(gen1App.aws, 'fetchRestApiRootResourceId').mockResolvedValue('root-resource-id'); + + const generator = new RestApiGenerator( + gen1App, + backendGenerator, + outputDir, + { + category: 'api', + resourceName: 'AdminQueries', + service: 'API Gateway', + key: 'api:API Gateway', + }, + logger, + ); + const ops = await generator.plan(); + await ops[0].execute(); + + const output = writtenFile('resource.ts'); + expect(output).toMatchInlineSnapshot(` + "import { + RestApi, + LambdaIntegration, + AuthorizationType, + Cors, + ResponseType, + CognitoUserPoolsAuthorizer, + } from 'aws-cdk-lib/aws-apigateway'; + import { Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; + import { Stack } from 'aws-cdk-lib'; + import type { Backend } from '../../backend'; + + const branchName = process.env.AWS_BRANCH ?? 'sandbox'; + + export function defineAdminQueriesApi(backend: Backend) { + const stack = backend.createStack('rest-api-stack-AdminQueries'); + const AdminQueriesApi = new RestApi(stack, 'RestApi', { + restApiName: \`AdminQueries-\${branchName}\`, + defaultCorsPreflightOptions: { + allowOrigins: Cors.ALL_ORIGINS, + allowMethods: Cors.ALL_METHODS, + allowHeaders: [ + 'Content-Type', + 'X-Amz-Date', + 'Authorization', + 'X-Api-Key', + 'X-Amz-Security-Token', + 'X-Amz-User-Agent', + ], + statusCode: 200, + }, + }); + AdminQueriesApi.addGatewayResponse('Default4XX', { + type: ResponseType.DEFAULT_4XX, + responseHeaders: { + 'Access-Control-Allow-Origin': "'*'", + 'Access-Control-Allow-Headers': + "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + 'Access-Control-Allow-Methods': + "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", + 'Access-Control-Expose-Headers': "'Date,X-Amzn-ErrorType'", + }, + }); + AdminQueriesApi.addGatewayResponse('Default5XX', { + type: ResponseType.DEFAULT_5XX, + responseHeaders: { + 'Access-Control-Allow-Origin': "'*'", + 'Access-Control-Allow-Headers': + "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + 'Access-Control-Allow-Methods': + "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", + 'Access-Control-Expose-Headers': "'Date,X-Amzn-ErrorType'", + }, + }); + const AdminQueriesd29134dbIntegration = new LambdaIntegration( + backend.AdminQueriesd29134db.resources.lambda + ); + const adminQueriesCognitoAuthorizer = new CognitoUserPoolsAuthorizer( + stack, + 'CognitoAuthorizer', + { + cognitoUserPools: [backend.auth.resources.userPool], + identitySource: 'method.request.header.Authorization', + } + ); + const adminQueriesMethodOptions = { + authorizationType: AuthorizationType.COGNITO, + authorizer: adminQueriesCognitoAuthorizer, + authorizationScopes: ['aws.cognito.signin.user.admin'], + }; + const gen1AdminQueriesApi = RestApi.fromRestApiAttributes( + stack, + 'Gen1AdminQueriesApi', + { + restApiId: 'abc123', + rootResourceId: 'root-resource-id', + } + ); + const gen1AdminQueriesPolicy = new Policy(stack, 'Gen1AdminQueriesPolicy', { + statements: [ + new PolicyStatement({ + actions: ['execute-api:Invoke'], + resources: [\`\${gen1AdminQueriesApi.arnForExecuteApi('GET', '/*')}\`], + }), + ], + }); + backend.auth.resources.authenticatedUserIamRole.attachInlinePolicy( + gen1AdminQueriesPolicy + ); + const root = AdminQueriesApi.root; + root.addMethod( + 'ANY', + AdminQueriesd29134dbIntegration, + adminQueriesMethodOptions + ); + const rootProxy = root.addProxy({ + anyMethod: false, + defaultIntegration: AdminQueriesd29134dbIntegration, + }); + rootProxy.addMethod( + 'ANY', + AdminQueriesd29134dbIntegration, + adminQueriesMethodOptions + ); + backend.AdminQueriesd29134db.resources.lambda.addToRolePolicy( + new PolicyStatement({ + actions: [ + 'cognito-idp:AdminAddUserToGroup', + 'cognito-idp:AdminConfirmSignUp', + 'cognito-idp:AdminDisableUser', + 'cognito-idp:AdminEnableUser', + 'cognito-idp:AdminGetUser', + 'cognito-idp:AdminListGroupsForUser', + 'cognito-idp:AdminRemoveUserFromGroup', + 'cognito-idp:AdminUserGlobalSignOut', + 'cognito-idp:ListGroups', + 'cognito-idp:ListUsers', + 'cognito-idp:ListUsersInGroup', + ], + resources: [ + backend.auth.resources.userPool.userPoolArn, + Stack.of(AdminQueriesApi).formatArn({ + service: 'cognito-idp', + resource: 'userpool', + resourceName: 'us-east-1_abc123', + }), + ], + }) + ); + backend.addOutput({ + custom: { + API: { + [AdminQueriesApi.restApiName]: { + endpoint: AdminQueriesApi.url.slice(0, -1), + region: Stack.of(AdminQueriesApi).region, + apiName: AdminQueriesApi.restApiName, + }, + }, + }, + }); + } + " + `); + }); + + it('does not apply AdminQueries wiring based only on a function name prefix', async () => { + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + api: { + ReportsApi: { + service: 'API Gateway', + output: { RootUrl: 'https://abc123.execute-api.us-east-1.amazonaws.com/main', ApiId: 'abc123' }, + }, + }, + auth: { myAuth: { service: 'Cognito', output: { UserPoolId: 'us-east-1_abc123' } } }, + }); + jest.spyOn(gen1App, 'cliInputs').mockReturnValue({ + paths: { '/reports': { lambdaFunction: 'AdminQueriesReports', permissions: { setting: 'open' } } }, + }); + jest.spyOn(gen1App.aws, 'fetchRestApiRootResourceId').mockResolvedValue('root-resource-id'); + + const generator = new RestApiGenerator( + gen1App, + backendGenerator, + outputDir, + { + category: 'api', + resourceName: 'ReportsApi', + service: 'API Gateway', + key: 'api:API Gateway', + }, + logger, + ); + const ops = await generator.plan(); + await ops[0].execute(); + + const output = writtenFile('resource.ts'); + expect(output).not.toContain('CognitoUserPoolsAuthorizer'); + expect(output).not.toContain('adminQueriesMethodOptions'); + expect(output).not.toContain('cognito-idp:AdminGetUser'); + }); + + it('does not render AdminQueries auth wiring when the auth category is absent', async () => { + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + api: { + AdminQueries: { + service: 'API Gateway', + output: { RootUrl: 'https://abc123.execute-api.us-east-1.amazonaws.com/main', ApiId: 'abc123' }, + dependsOn: [ + { category: 'auth', resourceName: 'missingAuth', attributes: ['UserPoolId'] }, + { category: 'function', resourceName: 'AdminQueriesd29134db', attributes: ['Arn', 'Name'] }, + ], + }, + }, + }); + jest.spyOn(gen1App, 'cliInputs').mockReturnValue({ + paths: { '/{proxy+}': { lambdaFunction: 'AdminQueriesd29134db', permissions: { setting: 'private' } } }, + }); + jest.spyOn(gen1App.aws, 'fetchRestApiRootResourceId').mockResolvedValue('root-resource-id'); + + const generator = new RestApiGenerator( + gen1App, + backendGenerator, + outputDir, + { + category: 'api', + resourceName: 'AdminQueries', + service: 'API Gateway', + key: 'api:API Gateway', + }, + logger, + ); + const ops = await generator.plan(); + await ops[0].execute(); + + const output = writtenFile('resource.ts'); + expect(output).not.toContain('CognitoUserPoolsAuthorizer'); + expect(output).not.toContain('backend.auth'); + expect(output).not.toContain('cognito-idp:AdminGetUser'); + }); + + it('warns and scopes AdminQueries permissions to the Gen2 pool when the Gen1 pool ID is missing', async () => { + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + api: { + AdminQueries: { + service: 'API Gateway', + output: { RootUrl: 'https://abc123.execute-api.us-east-1.amazonaws.com/main', ApiId: 'abc123' }, + dependsOn: [ + { category: 'auth', resourceName: 'myAuth', attributes: ['UserPoolId'] }, + { category: 'function', resourceName: 'AdminQueriesd29134db', attributes: ['Arn', 'Name'] }, + ], + }, + }, + auth: { myAuth: { service: 'Cognito', output: {} } }, + }); + jest.spyOn(gen1App, 'cliInputs').mockReturnValue({ + paths: { '/{proxy+}': { lambdaFunction: 'AdminQueriesd29134db', permissions: { setting: 'private' } } }, + }); + jest.spyOn(gen1App.aws, 'fetchRestApiRootResourceId').mockResolvedValue('root-resource-id'); + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(); + + const generator = new RestApiGenerator( + gen1App, + backendGenerator, + outputDir, + { + category: 'api', + resourceName: 'AdminQueries', + service: 'API Gateway', + key: 'api:API Gateway', + }, + logger, + ); + const ops = await generator.plan(); + await ops[0].execute(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("AdminQueries API 'AdminQueries' detected")); + const output = writtenFile('resource.ts'); + expect(output).toMatchInlineSnapshot(` + "import { + RestApi, + LambdaIntegration, + AuthorizationType, + Cors, + ResponseType, + CognitoUserPoolsAuthorizer, + } from 'aws-cdk-lib/aws-apigateway'; + import { Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; + import { Stack } from 'aws-cdk-lib'; + import type { Backend } from '../../backend'; + + const branchName = process.env.AWS_BRANCH ?? 'sandbox'; + + export function defineAdminQueriesApi(backend: Backend) { + const stack = backend.createStack('rest-api-stack-AdminQueries'); + const AdminQueriesApi = new RestApi(stack, 'RestApi', { + restApiName: \`AdminQueries-\${branchName}\`, + defaultCorsPreflightOptions: { + allowOrigins: Cors.ALL_ORIGINS, + allowMethods: Cors.ALL_METHODS, + allowHeaders: [ + 'Content-Type', + 'X-Amz-Date', + 'Authorization', + 'X-Api-Key', + 'X-Amz-Security-Token', + 'X-Amz-User-Agent', + ], + statusCode: 200, + }, + }); + AdminQueriesApi.addGatewayResponse('Default4XX', { + type: ResponseType.DEFAULT_4XX, + responseHeaders: { + 'Access-Control-Allow-Origin': "'*'", + 'Access-Control-Allow-Headers': + "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + 'Access-Control-Allow-Methods': + "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", + 'Access-Control-Expose-Headers': "'Date,X-Amzn-ErrorType'", + }, + }); + AdminQueriesApi.addGatewayResponse('Default5XX', { + type: ResponseType.DEFAULT_5XX, + responseHeaders: { + 'Access-Control-Allow-Origin': "'*'", + 'Access-Control-Allow-Headers': + "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + 'Access-Control-Allow-Methods': + "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", + 'Access-Control-Expose-Headers': "'Date,X-Amzn-ErrorType'", + }, + }); + const AdminQueriesd29134dbIntegration = new LambdaIntegration( + backend.AdminQueriesd29134db.resources.lambda + ); + const adminQueriesCognitoAuthorizer = new CognitoUserPoolsAuthorizer( + stack, + 'CognitoAuthorizer', + { + cognitoUserPools: [backend.auth.resources.userPool], + identitySource: 'method.request.header.Authorization', + } + ); + const adminQueriesMethodOptions = { + authorizationType: AuthorizationType.COGNITO, + authorizer: adminQueriesCognitoAuthorizer, + authorizationScopes: ['aws.cognito.signin.user.admin'], + }; + const gen1AdminQueriesApi = RestApi.fromRestApiAttributes( + stack, + 'Gen1AdminQueriesApi', + { + restApiId: 'abc123', + rootResourceId: 'root-resource-id', + } + ); + const gen1AdminQueriesPolicy = new Policy(stack, 'Gen1AdminQueriesPolicy', { + statements: [ + new PolicyStatement({ + actions: ['execute-api:Invoke'], + resources: [\`\${gen1AdminQueriesApi.arnForExecuteApi('GET', '/*')}\`], + }), + ], + }); + backend.auth.resources.authenticatedUserIamRole.attachInlinePolicy( + gen1AdminQueriesPolicy + ); + const root = AdminQueriesApi.root; + root.addMethod( + 'ANY', + AdminQueriesd29134dbIntegration, + adminQueriesMethodOptions + ); + const rootProxy = root.addProxy({ + anyMethod: false, + defaultIntegration: AdminQueriesd29134dbIntegration, + }); + rootProxy.addMethod( + 'ANY', + AdminQueriesd29134dbIntegration, + adminQueriesMethodOptions + ); + backend.AdminQueriesd29134db.resources.lambda.addToRolePolicy( + new PolicyStatement({ + actions: [ + 'cognito-idp:AdminAddUserToGroup', + 'cognito-idp:AdminConfirmSignUp', + 'cognito-idp:AdminDisableUser', + 'cognito-idp:AdminEnableUser', + 'cognito-idp:AdminGetUser', + 'cognito-idp:AdminListGroupsForUser', + 'cognito-idp:AdminRemoveUserFromGroup', + 'cognito-idp:AdminUserGlobalSignOut', + 'cognito-idp:ListGroups', + 'cognito-idp:ListUsers', + 'cognito-idp:ListUsersInGroup', + ], + resources: [backend.auth.resources.userPool.userPoolArn], + }) + ); + backend.addOutput({ + custom: { + API: { + [AdminQueriesApi.restApiName]: { + endpoint: AdminQueriesApi.url.slice(0, -1), + region: Stack.of(AdminQueriesApi).region, + apiName: AdminQueriesApi.restApiName, + }, + }, + }, + }); + } + " + `); + }); }); diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/rest-api/rest-api.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/rest-api/rest-api.generator.ts index 608ebef0127..765cd2482e7 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/rest-api/rest-api.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/rest-api/rest-api.generator.ts @@ -8,6 +8,17 @@ import { TS } from '../../ts'; import { RestApiRenderOptions, RestApiRenderer } from './rest-api.renderer'; import { SpinningLogger } from '../../../_common/spinning-logger'; +interface ResourceDependency { + readonly category: string; + readonly resourceName: string; + readonly attributes?: readonly string[]; +} + +interface AdminQueriesConfig { + readonly authResourceName: string; + readonly functionNames: readonly string[]; +} + /** * Generates a single REST API (API Gateway) resource and contributes * CDK constructs to backend.ts. @@ -39,8 +50,8 @@ export class RestApiGenerator implements Planner { } public async plan(): Promise { - const restApi = await this.readRestApiConfig(); const hasAuth = this.gen1App.categoryMeta('auth') !== undefined; + const restApi = await this.readRestApiConfig(hasAuth); return [ { @@ -70,17 +81,82 @@ export class RestApiGenerator implements Planner { * Reads the REST API definition for a single API Gateway resource * from local cli-inputs.json and amplify-meta.json. */ - private async readRestApiConfig(): Promise { + private async readRestApiConfig(hasAuth: boolean): Promise { const cliInputs = this.gen1App.cliInputs('api', this.resource.resourceName); const gen1ApiId = this.gen1App.resourceMetaOutput(this.resource, 'ApiId'); this.logger.debug(`Fetching REST API root resource for '${gen1ApiId}'`); const gen1RootResourceId = await this.gen1App.aws.fetchRestApiRootResourceId(gen1ApiId); + const paths = cliInputs.paths ?? {}; + const adminQueriesConfig = this.adminQueriesConfig(paths, hasAuth); return { apiName: this.resource.resourceName, exportedFunctionName: `define${this.resource.resourceName.charAt(0).toUpperCase() + this.resource.resourceName.slice(1)}Api`, - paths: cliInputs.paths ?? {}, + paths, gen1ApiId, gen1RootResourceId, + ...(adminQueriesConfig && { + adminQueriesFunctionNames: adminQueriesConfig.functionNames, + gen1UserPoolId: this.gen1UserPoolId(adminQueriesConfig.authResourceName), + }), }; } + + private adminQueriesConfig( + paths: Record, + hasAuth: boolean, + ): AdminQueriesConfig | undefined { + if (this.resource.resourceName !== 'AdminQueries') { + return undefined; + } + + const rawDependencies = this.gen1App.resourceMeta(this.resource).dependsOn; + const dependencies = (Array.isArray(rawDependencies) ? rawDependencies : []) as ResourceDependency[]; + const authDependency = dependencies.find( + (dependency) => dependency.category === 'auth' && dependency.attributes?.includes('UserPoolId'), + ); + const functionDependencies = new Set( + dependencies + .filter( + (dependency) => + dependency.category === 'function' && dependency.attributes?.includes('Arn') && dependency.attributes.includes('Name'), + ) + .map((dependency) => dependency.resourceName), + ); + const functionNames = Array.from( + new Set( + Object.values(paths) + .map((pathConfig) => pathConfig.lambdaFunction) + .filter((name): name is string => !!name && functionDependencies.has(name)), + ), + ); + + if (!authDependency || functionNames.length === 0) { + return undefined; + } + + const authCategory = this.gen1App.categoryMeta('auth'); + if (!hasAuth || !authCategory?.[authDependency.resourceName]) { + this.logger.warn( + `AdminQueries API '${this.resource.resourceName}' detected but its Cognito auth dependency ` + + `'${authDependency.resourceName}' is missing from amplify-meta.json; generated API will not include AdminQueries auth wiring`, + ); + return undefined; + } + + return { authResourceName: authDependency.resourceName, functionNames }; + } + + private gen1UserPoolId(authResourceName: string): string | undefined { + const authCategory = this.gen1App.categoryMeta('auth'); + const authResourceMeta = authCategory?.[authResourceName] as { readonly output?: { readonly UserPoolId?: string } } | undefined; + const gen1UserPoolId = authResourceMeta?.output?.UserPoolId; + if (!gen1UserPoolId) { + this.logger.warn( + `AdminQueries API '${this.resource.resourceName}' detected but no Gen1 Cognito UserPoolId was found for ` + + `'${authResourceName}' in amplify-meta.json; generated IAM policy will scope to the Gen2 user pool only`, + ); + } + + return gen1UserPoolId; + } } diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/rest-api/rest-api.renderer.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/rest-api/rest-api.renderer.ts index a5109374c99..122a5ac0e87 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/rest-api/rest-api.renderer.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/rest-api/rest-api.renderer.ts @@ -4,7 +4,8 @@ import { newLineIdentifier, TS } from '../../ts'; const factory = ts.factory; /** - * Complete definition of a REST API extracted from Gen1 cli-inputs.json. + * Complete definition of a REST API extracted from Gen1 cli-inputs.json and + * amplify-meta.json. */ /* eslint-disable @typescript-eslint/no-explicit-any -- paths are untyped Gen1 cli-inputs.json */ export interface RestApiRenderOptions { @@ -13,6 +14,8 @@ export interface RestApiRenderOptions { readonly paths: Record; readonly gen1ApiId: string; readonly gen1RootResourceId: string; + readonly adminQueriesFunctionNames?: readonly string[]; + readonly gen1UserPoolId?: string; } /* eslint-enable @typescript-eslint/no-explicit-any */ @@ -37,7 +40,7 @@ export class RestApiRenderer { */ public render(restApi: RestApiRenderOptions): ts.NodeArray { return factory.createNodeArray([ - ...this.renderImports(), + ...this.renderImports(restApi), this.renderBackendTypeImport(), newLineIdentifier, TS.createBranchNameDeclaration(), @@ -46,9 +49,14 @@ export class RestApiRenderer { ] as ts.Statement[]); } - private renderImports(): ts.ImportDeclaration[] { + private renderImports(restApi: RestApiRenderOptions): ts.ImportDeclaration[] { + const apiGatewayImports = ['RestApi', 'LambdaIntegration', 'AuthorizationType', 'Cors', 'ResponseType']; + if (this.isAdminQueriesApi(restApi)) { + apiGatewayImports.push('CognitoUserPoolsAuthorizer'); + } + return [ - TS.namedImport('aws-cdk-lib/aws-apigateway', 'RestApi', 'LambdaIntegration', 'AuthorizationType', 'Cors', 'ResponseType'), + TS.namedImport('aws-cdk-lib/aws-apigateway', ...apiGatewayImports), TS.namedImport('aws-cdk-lib/aws-iam', 'Policy', 'PolicyStatement'), TS.namedImport('aws-cdk-lib', 'Stack'), ]; @@ -71,6 +79,7 @@ export class RestApiRenderer { const apiVarName = `${sanitizedName}Api`; const gen1ApiVarName = `gen1${sanitizedName}Api`; const gen1PolicyVarName = `gen1${sanitizedName}Policy`; + const adminQueriesMethodOptionsVarName = 'adminQueriesMethodOptions'; statements.push(this.renderStack(restApi, 'stack')); statements.push(this.renderRestApiConstruct(restApi, 'stack', apiVarName)); @@ -79,6 +88,10 @@ export class RestApiRenderer { const integrations = this.renderLambdaIntegrations(restApi); statements.push(...integrations.statements); + if (this.isAdminQueriesApi(restApi)) { + statements.push(...this.renderAdminQueriesAuthorizer('stack', adminQueriesMethodOptionsVarName)); + } + statements.push(this.renderGen1ApiReference(restApi, 'stack', gen1ApiVarName)); statements.push(this.renderGen1Policy(restApi, 'stack', gen1ApiVarName, gen1PolicyVarName)); @@ -86,8 +99,16 @@ export class RestApiRenderer { statements.push(this.renderGen1PolicyAttachment(gen1PolicyVarName)); } - statements.push(...this.renderPaths(restApi, apiVarName, integrations.map)); + statements.push( + ...this.renderPaths( + restApi, + apiVarName, + integrations.map, + this.isAdminQueriesApi(restApi) ? adminQueriesMethodOptionsVarName : undefined, + ), + ); statements.push(...this.renderPathPolicies(restApi, apiVarName, 'stack', gen1ApiVarName)); + statements.push(...this.renderAdminQueriesLambdaPolicies(restApi, apiVarName)); statements.push(this.renderOutput(apiVarName)); return statements; @@ -143,6 +164,18 @@ export class RestApiRenderer { } private renderRestApiConstruct(restApi: RestApiRenderOptions, stackVarName: string, apiVarName: string): ts.Statement { + const restApiProps = [ + factory.createPropertyAssignment( + 'restApiName', + factory.createTemplateExpression(factory.createTemplateHead(`${restApi.apiName}-`), [ + factory.createTemplateSpan(factory.createIdentifier('branchName'), factory.createTemplateTail('')), + ]), + ), + ]; + if (this.isAdminQueriesApi(restApi)) { + restApiProps.push(this.renderCorsPreflightOptions()); + } + return factory.createVariableStatement( [], factory.createVariableDeclarationList( @@ -154,17 +187,7 @@ export class RestApiRenderer { factory.createNewExpression(factory.createIdentifier('RestApi'), undefined, [ factory.createIdentifier(stackVarName), factory.createStringLiteral('RestApi'), - factory.createObjectLiteralExpression( - [ - factory.createPropertyAssignment( - 'restApiName', - factory.createTemplateExpression(factory.createTemplateHead(`${restApi.apiName}-`), [ - factory.createTemplateSpan(factory.createIdentifier('branchName'), factory.createTemplateTail('')), - ]), - ), - ], - true, - ), + factory.createObjectLiteralExpression(restApiProps, true), ]), ), ], @@ -267,6 +290,42 @@ export class RestApiRenderer { return { statements, map }; } + private renderAdminQueriesAuthorizer(stackVarName: string, methodOptionsVarName: string): ts.Statement[] { + return [ + TS.declareConst( + 'adminQueriesCognitoAuthorizer', + factory.createNewExpression(factory.createIdentifier('CognitoUserPoolsAuthorizer'), undefined, [ + factory.createIdentifier(stackVarName), + factory.createStringLiteral('CognitoAuthorizer'), + factory.createObjectLiteralExpression( + [ + factory.createPropertyAssignment( + 'cognitoUserPools', + factory.createArrayLiteralExpression([TS.propAccess('backend', 'auth', 'resources', 'userPool')]), + ), + factory.createPropertyAssignment('identitySource', factory.createStringLiteral('method.request.header.Authorization')), + ], + true, + ), + ]), + ), + TS.declareConst( + methodOptionsVarName, + factory.createObjectLiteralExpression( + [ + TS.enumProp('authorizationType', 'AuthorizationType', 'COGNITO'), + factory.createPropertyAssignment('authorizer', factory.createIdentifier('adminQueriesCognitoAuthorizer')), + factory.createPropertyAssignment( + 'authorizationScopes', + factory.createArrayLiteralExpression([factory.createStringLiteral('aws.cognito.signin.user.admin')]), + ), + ], + true, + ), + ), + ]; + } + private renderGen1ApiReference(restApi: RestApiRenderOptions, stackVarName: string, gen1ApiVarName: string): ts.Statement { return factory.createVariableStatement( [], @@ -385,7 +444,12 @@ export class RestApiRenderer { ); } - private renderPaths(restApi: RestApiRenderOptions, apiVarName: string, integrations: ReadonlyMap): ts.Statement[] { + private renderPaths( + restApi: RestApiRenderOptions, + apiVarName: string, + integrations: ReadonlyMap, + methodOptionsVarName?: string, + ): ts.Statement[] { const statements: ts.Statement[] = []; for (const [pathName, pathConfig] of Object.entries(restApi.paths)) { @@ -443,31 +507,127 @@ export class RestApiRenderer { factory.createCallExpression( factory.createPropertyAccessExpression(factory.createIdentifier(resourceName), factory.createIdentifier('addMethod')), undefined, - [factory.createStringLiteral('ANY'), factory.createIdentifier(integrationVar)], + [ + factory.createStringLiteral('ANY'), + factory.createIdentifier(integrationVar), + ...(methodOptionsVarName ? [factory.createIdentifier(methodOptionsVarName)] : []), + ], ), ), ); - statements.push( - factory.createExpressionStatement( - factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier(resourceName), factory.createIdentifier('addProxy')), - undefined, + const addProxyCall = factory.createCallExpression( + factory.createPropertyAccessExpression(factory.createIdentifier(resourceName), factory.createIdentifier('addProxy')), + undefined, + [ + factory.createObjectLiteralExpression( [ + factory.createPropertyAssignment('anyMethod', methodOptionsVarName ? factory.createFalse() : factory.createTrue()), + factory.createPropertyAssignment('defaultIntegration', factory.createIdentifier(integrationVar)), + ], + true, + ), + ], + ); + + if (methodOptionsVarName) { + const proxyVarName = `${resourceName}Proxy`; + statements.push(TS.declareConst(proxyVarName, addProxyCall)); + statements.push( + factory.createExpressionStatement( + factory.createCallExpression( + factory.createPropertyAccessExpression(factory.createIdentifier(proxyVarName), factory.createIdentifier('addMethod')), + undefined, + [ + factory.createStringLiteral('ANY'), + factory.createIdentifier(integrationVar), + factory.createIdentifier(methodOptionsVarName), + ], + ), + ), + ); + } else { + statements.push(factory.createExpressionStatement(addProxyCall)); + } + } + + return statements; + } + + private renderAdminQueriesLambdaPolicies(restApi: RestApiRenderOptions, apiVarName: string): ts.Statement[] { + if (!this.isAdminQueriesApi(restApi)) { + return []; + } + + const functionNames = restApi.adminQueriesFunctionNames ?? []; + + const actions = [ + 'cognito-idp:AdminAddUserToGroup', + 'cognito-idp:AdminConfirmSignUp', + 'cognito-idp:AdminDisableUser', + 'cognito-idp:AdminEnableUser', + 'cognito-idp:AdminGetUser', + 'cognito-idp:AdminListGroupsForUser', + 'cognito-idp:AdminRemoveUserFromGroup', + 'cognito-idp:AdminUserGlobalSignOut', + 'cognito-idp:ListGroups', + 'cognito-idp:ListUsers', + 'cognito-idp:ListUsersInGroup', + ]; + + return functionNames.map((functionName) => { + const resources = [ + TS.propAccess('backend', 'auth', 'resources', 'userPool', 'userPoolArn') as ts.Expression, + ...(restApi.gen1UserPoolId ? [this.renderGen1UserPoolArn(apiVarName, restApi.gen1UserPoolId)] : []), + ]; + + return factory.createExpressionStatement( + factory.createCallExpression( + factory.createPropertyAccessExpression( + TS.propAccess('backend', functionName, 'resources', 'lambda') as ts.Expression, + factory.createIdentifier('addToRolePolicy'), + ), + undefined, + [ + factory.createNewExpression(factory.createIdentifier('PolicyStatement'), undefined, [ factory.createObjectLiteralExpression( [ - factory.createPropertyAssignment('anyMethod', factory.createTrue()), - factory.createPropertyAssignment('defaultIntegration', factory.createIdentifier(integrationVar)), + factory.createPropertyAssignment( + 'actions', + factory.createArrayLiteralExpression(actions.map((action) => factory.createStringLiteral(action))), + ), + factory.createPropertyAssignment('resources', factory.createArrayLiteralExpression(resources)), ], true, ), - ], - ), + ]), + ], ), ); - } + }); + } - return statements; + private renderGen1UserPoolArn(apiVarName: string, gen1UserPoolId: string): ts.CallExpression { + const stackOfApi = factory.createCallExpression( + factory.createPropertyAccessExpression(factory.createIdentifier('Stack'), factory.createIdentifier('of')), + undefined, + [factory.createIdentifier(apiVarName)], + ); + + return factory.createCallExpression( + factory.createPropertyAccessExpression(stackOfApi, factory.createIdentifier('formatArn')), + undefined, + [ + factory.createObjectLiteralExpression( + [ + factory.createPropertyAssignment('service', factory.createStringLiteral('cognito-idp')), + factory.createPropertyAssignment('resource', factory.createStringLiteral('userpool')), + factory.createPropertyAssignment('resourceName', factory.createStringLiteral(gen1UserPoolId)), + ], + true, + ), + ], + ); } private renderCorsPreflightOptions(): ts.PropertyAssignment { @@ -855,4 +1015,8 @@ export class RestApiRenderer { (p: any) => p.permissions?.setting === 'private' || p.permissions?.setting === 'protected', ); } + + private isAdminQueriesApi(restApi: RestApiRenderOptions): boolean { + return this.hasAuth && (restApi.adminQueriesFunctionNames?.length ?? 0) > 0; + } }