From 20318fc89fecb66bffa172caca572656f483c557 Mon Sep 17 00:00:00 2001 From: hariharan077 Date: Tue, 9 Jun 2026 16:09:53 +0530 Subject: [PATCH 1/2] fix(cli): generate admin queries auth wiring --- .../rest-api/rest-api.generator.test.ts | 48 ++++ .../amplify/rest-api/rest-api.generator.ts | 36 ++- .../amplify/rest-api/rest-api.renderer.ts | 214 +++++++++++++++--- 3 files changed, 269 insertions(+), 29 deletions(-) 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..6552110c3b6 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,52 @@ 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' }, + }, + }, + auth: { myAuth: { service: 'Cognito', output: { UserPoolId: 'us-east-1_abc123' } } }, + }); + jest.spyOn(gen1App, 'resourceMetaOutput').mockReturnValue('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).toContain('CognitoUserPoolsAuthorizer'); + expect(output).toContain('defaultCorsPreflightOptions'); + expect(output).toContain('authorizationType: AuthorizationType.COGNITO'); + expect(output).toContain("authorizationScopes: ['aws.cognito.signin.user.admin']"); + expect(output).toMatch(/root\.addMethod\(\s*'ANY',\s*AdminQueriesd29134dbIntegration,\s*adminQueriesMethodOptions\s*\)/); + expect(output).toContain('anyMethod: false'); + expect(output).toMatch(/rootProxy\.addMethod\(\s*'ANY',\s*AdminQueriesd29134dbIntegration,\s*adminQueriesMethodOptions\s*\)/); + expect(output).toContain('backend.AdminQueriesd29134db.resources.lambda.addToRolePolicy'); + expect(output).toContain("'cognito-idp:AdminGetUser'"); + expect(output).toContain("'cognito-idp:ListUsersInGroup'"); + expect(output).toContain('backend.auth.resources.userPool.userPoolArn'); + expect(output).toContain('userpool/us-east-1_abc123'); + expect(output).toContain('backend.addOutput'); + }); }); 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..0b42a0f1234 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 @@ -75,12 +75,46 @@ export class RestApiGenerator implements Planner { 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 adminQueriesFunctionNames = this.adminQueriesFunctionNames(this.resource.resourceName, paths); 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, + ...(adminQueriesFunctionNames.length > 0 && { + adminQueriesFunctionNames, + gen1UserPoolId: this.gen1UserPoolId(), + }), }; } + + private adminQueriesFunctionNames(apiName: string, paths: Record): string[] { + const functions = new Set( + Object.values(paths) + .map((pathConfig) => pathConfig.lambdaFunction) + .filter((name): name is string => !!name), + ); + if (apiName === 'AdminQueries') { + return Array.from(functions); + } + return Array.from(functions).filter((name) => name.startsWith('AdminQueries')); + } + + private gen1UserPoolId(): string | undefined { + const authCategory = this.gen1App.categoryMeta('auth'); + if (!authCategory) { + return undefined; + } + + for (const resourceMeta of Object.values(authCategory)) { + const output = (resourceMeta as { readonly output?: { readonly UserPoolId?: string } }).output; + if (output?.UserPoolId) { + return output.UserPoolId; + } + } + + return undefined; + } } 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..1ca59e2c6be 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 @@ -13,6 +13,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 +39,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 +48,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 +78,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 +87,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 +98,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 +163,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 +186,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 +289,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 +443,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 +506,122 @@ 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[] { + const functionNames = restApi.adminQueriesFunctionNames ?? []; + if (functionNames.length === 0) { + return []; + } + + 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.TemplateExpression { + const stackOfApi = factory.createCallExpression( + factory.createPropertyAccessExpression(factory.createIdentifier('Stack'), factory.createIdentifier('of')), + undefined, + [factory.createIdentifier(apiVarName)], + ); + + return factory.createTemplateExpression(factory.createTemplateHead('arn:aws:cognito-idp:'), [ + factory.createTemplateSpan( + factory.createPropertyAccessExpression(stackOfApi, factory.createIdentifier('region')), + factory.createTemplateMiddle(':'), + ), + factory.createTemplateSpan( + factory.createPropertyAccessExpression(stackOfApi, factory.createIdentifier('account')), + factory.createTemplateTail(`:userpool/${gen1UserPoolId}`), + ), + ]); } private renderCorsPreflightOptions(): ts.PropertyAssignment { @@ -855,4 +1009,8 @@ export class RestApiRenderer { (p: any) => p.permissions?.setting === 'private' || p.permissions?.setting === 'protected', ); } + + private isAdminQueriesApi(restApi: RestApiRenderOptions): boolean { + return (restApi.adminQueriesFunctionNames?.length ?? 0) > 0; + } } From b78acb6c3c59c1e548b53a887b85db40266e699b Mon Sep 17 00:00:00 2001 From: "hariharan077 (AI)" Date: Wed, 26 Aug 2026 18:23:28 +0530 Subject: [PATCH 2/2] fix(cli-internal): validate admin queries migration wiring Require canonical AdminQueries dependencies before generating auth. Generate IAM policies only for verified metadata. Build Gen1 user pool ARNs with CDK's partition-aware formatter. Warn when the referenced pool is unavailable. Cover prefix collisions, absent auth, missing pool IDs, and full output snapshots. Validate with the cli-internal build and full unit suite. --- Prompt: do it. make sure you verify and test if its all working by reproducing the error and the patch before pushing and then comment and reply whatever is necessary you have my approval --- .../src/commands/gen2-migration/generate.md | 10 + .../rest-api/rest-api.generator.test.ts | 416 +++++++++++++++++- .../amplify/rest-api/rest-api.generator.ts | 88 +++- .../amplify/rest-api/rest-api.renderer.ts | 36 +- 4 files changed, 498 insertions(+), 52 deletions(-) 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 6552110c3b6..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 @@ -1081,11 +1081,14 @@ describe('RestApiGenerator', () => { 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, 'resourceMetaOutput').mockReturnValue('abc123'); jest.spyOn(gen1App, 'cliInputs').mockReturnValue({ paths: { '/{proxy+}': { lambdaFunction: 'AdminQueriesd29134db', permissions: { setting: 'private' } } }, }); @@ -1107,18 +1110,403 @@ describe('RestApiGenerator', () => { await ops[0].execute(); const output = writtenFile('resource.ts'); - expect(output).toContain('CognitoUserPoolsAuthorizer'); - expect(output).toContain('defaultCorsPreflightOptions'); - expect(output).toContain('authorizationType: AuthorizationType.COGNITO'); - expect(output).toContain("authorizationScopes: ['aws.cognito.signin.user.admin']"); - expect(output).toMatch(/root\.addMethod\(\s*'ANY',\s*AdminQueriesd29134dbIntegration,\s*adminQueriesMethodOptions\s*\)/); - expect(output).toContain('anyMethod: false'); - expect(output).toMatch(/rootProxy\.addMethod\(\s*'ANY',\s*AdminQueriesd29134dbIntegration,\s*adminQueriesMethodOptions\s*\)/); - expect(output).toContain('backend.AdminQueriesd29134db.resources.lambda.addToRolePolicy'); - expect(output).toContain("'cognito-idp:AdminGetUser'"); - expect(output).toContain("'cognito-idp:ListUsersInGroup'"); - expect(output).toContain('backend.auth.resources.userPool.userPoolArn'); - expect(output).toContain('userpool/us-east-1_abc123'); - expect(output).toContain('backend.addOutput'); + 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 0b42a0f1234..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,51 +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 adminQueriesFunctionNames = this.adminQueriesFunctionNames(this.resource.resourceName, 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, gen1ApiId, gen1RootResourceId, - ...(adminQueriesFunctionNames.length > 0 && { - adminQueriesFunctionNames, - gen1UserPoolId: this.gen1UserPoolId(), + ...(adminQueriesConfig && { + adminQueriesFunctionNames: adminQueriesConfig.functionNames, + gen1UserPoolId: this.gen1UserPoolId(adminQueriesConfig.authResourceName), }), }; } - private adminQueriesFunctionNames(apiName: string, paths: Record): string[] { - const functions = new Set( - Object.values(paths) - .map((pathConfig) => pathConfig.lambdaFunction) - .filter((name): name is string => !!name), + 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), ); - if (apiName === 'AdminQueries') { - return Array.from(functions); + 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; } - return Array.from(functions).filter((name) => name.startsWith('AdminQueries')); - } - private gen1UserPoolId(): string | undefined { const authCategory = this.gen1App.categoryMeta('auth'); - if (!authCategory) { + 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; } - for (const resourceMeta of Object.values(authCategory)) { - const output = (resourceMeta as { readonly output?: { readonly UserPoolId?: string } }).output; - if (output?.UserPoolId) { - return output.UserPoolId; - } + 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 undefined; + 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 1ca59e2c6be..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 { @@ -554,11 +555,12 @@ export class RestApiRenderer { } private renderAdminQueriesLambdaPolicies(restApi: RestApiRenderOptions, apiVarName: string): ts.Statement[] { - const functionNames = restApi.adminQueriesFunctionNames ?? []; - if (functionNames.length === 0) { + if (!this.isAdminQueriesApi(restApi)) { return []; } + const functionNames = restApi.adminQueriesFunctionNames ?? []; + const actions = [ 'cognito-idp:AdminAddUserToGroup', 'cognito-idp:AdminConfirmSignUp', @@ -605,23 +607,27 @@ export class RestApiRenderer { }); } - private renderGen1UserPoolArn(apiVarName: string, gen1UserPoolId: string): ts.TemplateExpression { + 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.createTemplateExpression(factory.createTemplateHead('arn:aws:cognito-idp:'), [ - factory.createTemplateSpan( - factory.createPropertyAccessExpression(stackOfApi, factory.createIdentifier('region')), - factory.createTemplateMiddle(':'), - ), - factory.createTemplateSpan( - factory.createPropertyAccessExpression(stackOfApi, factory.createIdentifier('account')), - factory.createTemplateTail(`:userpool/${gen1UserPoolId}`), - ), - ]); + 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 { @@ -1011,6 +1017,6 @@ export class RestApiRenderer { } private isAdminQueriesApi(restApi: RestApiRenderOptions): boolean { - return (restApi.adminQueriesFunctionNames?.length ?? 0) > 0; + return this.hasAuth && (restApi.adminQueriesFunctionNames?.length ?? 0) > 0; } }