From 6033c9a01322be74f8ae7ddd0a3856cc22e28975 Mon Sep 17 00:00:00 2001 From: Luca Pizzini Date: Mon, 24 Jul 2023 14:33:05 +0200 Subject: [PATCH] feat(apprunner): make `Service` implement `IGrantable` (#26130) Implementing `IGrantable` for cases when it's needed to grant permissions to a `Service` instance. For example: ``` declare const bucket: IBucket; const service = new apprunner.Service(this, 'Service', { source: apprunner.Source.fromEcrPublic({ imageConfiguration: { port: 8000 }, imageIdentifier: 'public.ecr.aws/aws-containers/hello-app-runner:latest', }), }); bucket.grantRead(service); ``` Closes #26089. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../@aws-cdk/aws-apprunner-alpha/README.md | 24 +++++- .../aws-apprunner-alpha/lib/service.ts | 20 +++-- .../integ-apprunner-ecr-public.template.json | 28 ++++++- .../integ-apprunner.template.json | 54 ++++++++++++- .../integ-apprunner.template.json | 52 ++++++++++++- .../integ-apprunner.template.json | 54 ++++++++++++- .../aws-apprunner-alpha/test/service.test.ts | 77 +++++++++++++++++++ 7 files changed, 291 insertions(+), 18 deletions(-) diff --git a/packages/@aws-cdk/aws-apprunner-alpha/README.md b/packages/@aws-cdk/aws-apprunner-alpha/README.md index 80e3bbf100830..74989c792bfda 100644 --- a/packages/@aws-cdk/aws-apprunner-alpha/README.md +++ b/packages/@aws-cdk/aws-apprunner-alpha/README.md @@ -35,6 +35,8 @@ The `Service` construct allows you to create AWS App Runner services with `ECR P - `Source.fromAsset()` - To define the source from local asset directory. +The `Service` construct implements `IGrantable`. + ## ECR Public To create a `Service` with ECR Public: @@ -124,7 +126,27 @@ new apprunner.Service(this, 'Service', { You are allowed to define `instanceRole` and `accessRole` for the `Service`. `instanceRole` - The IAM role that provides permissions to your App Runner service. These are permissions that -your code needs when it calls any AWS APIs. +your code needs when it calls any AWS APIs. If not defined, a new instance role will be generated +when required. + +To add IAM policy statements to this role, use `addToRolePolicy()`: + +```ts +import * as iam from 'aws-cdk-lib/aws-iam'; + +const service = new apprunner.Service(this, 'Service', { + source: apprunner.Source.fromEcrPublic({ + imageConfiguration: { port: 8000 }, + imageIdentifier: 'public.ecr.aws/aws-containers/hello-app-runner:latest', + }), +}); + +service.addToRolePolicy(new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['s3:GetObject'], + resources: ['*'], +})) +``` `accessRole` - The IAM role that grants the App Runner service access to a source repository. It's required for ECR image repositories (but not for ECR Public repositories). If not defined, a new access role will be generated diff --git a/packages/@aws-cdk/aws-apprunner-alpha/lib/service.ts b/packages/@aws-cdk/aws-apprunner-alpha/lib/service.ts index 8f00645f1c061..afa6ea319b152 100644 --- a/packages/@aws-cdk/aws-apprunner-alpha/lib/service.ts +++ b/packages/@aws-cdk/aws-apprunner-alpha/lib/service.ts @@ -689,7 +689,7 @@ export interface ServiceProps { * * @see https://docs.aws.amazon.com/apprunner/latest/dg/security_iam_service-with-iam.html#security_iam_service-with-iam-roles-service.instance * - * @default - no instance role attached. + * @default - generate a new instance role. */ readonly instanceRole?: iam.IRole; @@ -959,7 +959,7 @@ export abstract class Secret { /** * The App Runner Service. */ -export class Service extends cdk.Resource { +export class Service extends cdk.Resource implements iam.IGrantable { /** * Import from service name. */ @@ -993,9 +993,10 @@ export class Service extends cdk.Resource { return new Import(scope, id); } + public readonly grantPrincipal: iam.IPrincipal; private readonly props: ServiceProps; private accessRole?: iam.IRole; - private instanceRole?: iam.IRole; + private instanceRole: iam.IRole; private source: SourceConfig; /** @@ -1051,7 +1052,8 @@ export class Service extends cdk.Resource { this.source = source; this.props = props; - this.instanceRole = this.props.instanceRole; + this.instanceRole = this.props.instanceRole ?? this.createInstanceRole(); + this.grantPrincipal = this.instanceRole; const environmentVariables = this.getEnvironmentVariables(); const environmentSecrets = this.getEnvironmentSecrets(); @@ -1117,6 +1119,13 @@ export class Service extends cdk.Resource { this.serviceName = cdk.Fn.select(1, cdk.Fn.split('/', resourceFullName)); } + /** + * Adds a statement to the instance role. + */ + public addToRolePolicy(statement: iam.PolicyStatement) { + this.instanceRole.addToPrincipalPolicy(statement); + } + /** * This method adds an environment variable to the App Runner service. */ @@ -1134,9 +1143,6 @@ export class Service extends cdk.Resource { if (name.startsWith('AWSAPPRUNNER')) { throw new Error(`Environment secret key ${name} with a prefix of AWSAPPRUNNER is not allowed`); } - if (!this.instanceRole) { - this.instanceRole = this.createInstanceRole(); - } secret.grantRead(this.instanceRole); this.secrets.push({ name: name, value: secret.arn }); } diff --git a/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-ecr-public.js.snapshot/integ-apprunner-ecr-public.template.json b/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-ecr-public.js.snapshot/integ-apprunner-ecr-public.template.json index 0ad0186e6cd76..47dbeb581f9f4 100644 --- a/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-ecr-public.js.snapshot/integ-apprunner-ecr-public.template.json +++ b/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-ecr-public.js.snapshot/integ-apprunner-ecr-public.template.json @@ -14,7 +14,14 @@ "ImageRepositoryType": "ECR_PUBLIC" } }, - "InstanceConfiguration": {}, + "InstanceConfiguration": { + "InstanceRoleArn": { + "Fn::GetAtt": [ + "Service1InstanceRole8CBC81F1", + "Arn" + ] + } + }, "NetworkConfiguration": { "EgressConfiguration": { "EgressType": "DEFAULT" @@ -22,7 +29,24 @@ }, "ServiceName": "service1" } - } + }, + "Service1InstanceRole8CBC81F1": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "tasks.apprunner.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + } }, "Outputs": { "URL1": { diff --git a/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-ecr.js.snapshot/integ-apprunner.template.json b/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-ecr.js.snapshot/integ-apprunner.template.json index 83013f96c6e09..f3c01fbb4534b 100644 --- a/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-ecr.js.snapshot/integ-apprunner.template.json +++ b/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-ecr.js.snapshot/integ-apprunner.template.json @@ -92,7 +92,14 @@ "ImageRepositoryType": "ECR" } }, - "InstanceConfiguration": {}, + "InstanceConfiguration": { + "InstanceRoleArn": { + "Fn::GetAtt": [ + "Service3InstanceRoleD40BEE82", + "Arn" + ] + } + }, "NetworkConfiguration": { "EgressConfiguration": { "EgressType": "DEFAULT" @@ -211,14 +218,55 @@ "ImageRepositoryType": "ECR" } }, - "InstanceConfiguration": {}, + "InstanceConfiguration": { + "InstanceRoleArn": { + "Fn::GetAtt": [ + "Service2InstanceRole3F57F2AA", + "Arn" + ] + } + }, "NetworkConfiguration": { "EgressConfiguration": { "EgressType": "DEFAULT" } } } - } + }, + "Service3InstanceRoleD40BEE82": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "tasks.apprunner.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "Service2InstanceRole3F57F2AA": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "tasks.apprunner.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + } }, "Outputs": { "URL3": { diff --git a/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-github.js.snapshot/integ-apprunner.template.json b/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-github.js.snapshot/integ-apprunner.template.json index 07b02b87671e9..fbf688bbfb608 100644 --- a/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-github.js.snapshot/integ-apprunner.template.json +++ b/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-github.js.snapshot/integ-apprunner.template.json @@ -18,7 +18,14 @@ } } }, - "InstanceConfiguration": {}, + "InstanceConfiguration": { + "InstanceRoleArn": { + "Fn::GetAtt": [ + "Service4InstanceRole26B443A0", + "Arn" + ] + } + }, "NetworkConfiguration": { "EgressConfiguration": { "EgressType": "DEFAULT" @@ -50,13 +57,54 @@ } } }, - "InstanceConfiguration": {}, + "InstanceConfiguration": { + "InstanceRoleArn": { + "Fn::GetAtt": [ + "Service5InstanceRole94C07D84", + "Arn" + ] + } + }, "NetworkConfiguration": { "EgressConfiguration": { "EgressType": "DEFAULT" } } } + }, + "Service4InstanceRole26B443A0": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "tasks.apprunner.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "Service5InstanceRole94C07D84": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "tasks.apprunner.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } } }, "Outputs": { diff --git a/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-vpc-connector.js.snapshot/integ-apprunner.template.json b/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-vpc-connector.js.snapshot/integ-apprunner.template.json index ed52375306ed4..bd3398fca1d38 100644 --- a/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-vpc-connector.js.snapshot/integ-apprunner.template.json +++ b/packages/@aws-cdk/aws-apprunner-alpha/test/integ.service-vpc-connector.js.snapshot/integ-apprunner.template.json @@ -442,7 +442,14 @@ "ImageRepositoryType": "ECR_PUBLIC" } }, - "InstanceConfiguration": {}, + "InstanceConfiguration": { + "InstanceRoleArn": { + "Fn::GetAtt": [ + "Service6InstanceRole7220D460", + "Arn" + ] + } + }, "NetworkConfiguration": { "EgressConfiguration": { "EgressType": "VPC", @@ -469,7 +476,14 @@ "ImageRepositoryType": "ECR_PUBLIC" } }, - "InstanceConfiguration": {}, + "InstanceConfiguration": { + "InstanceRoleArn": { + "Fn::GetAtt": [ + "Service7InstanceRoleFD40F312", + "Arn" + ] + } + }, "NetworkConfiguration": { "EgressConfiguration": { "EgressType": "VPC", @@ -482,7 +496,41 @@ } } } - } + }, + "Service6InstanceRole7220D460": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "tasks.apprunner.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "Service7InstanceRoleFD40F312": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "tasks.apprunner.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + } }, "Outputs": { "URL6": { diff --git a/packages/@aws-cdk/aws-apprunner-alpha/test/service.test.ts b/packages/@aws-cdk/aws-apprunner-alpha/test/service.test.ts index 05f7254c5a0d2..8fc2465e4a02b 100644 --- a/packages/@aws-cdk/aws-apprunner-alpha/test/service.test.ts +++ b/packages/@aws-cdk/aws-apprunner-alpha/test/service.test.ts @@ -6,6 +6,7 @@ import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import * as ssm from 'aws-cdk-lib/aws-ssm'; +import * as s3 from 'aws-cdk-lib/aws-s3'; import { testDeprecated } from '@aws-cdk/cdk-build-tools'; import * as cdk from 'aws-cdk-lib'; import * as apprunner from '../lib'; @@ -1273,4 +1274,80 @@ testDeprecated('Using both environmentVariables and environment should throw an }), }); }).toThrow(/You cannot set both \'environmentVariables\' and \'environment\' properties./); +}); + +test('Service is grantable', () => { + // GIVEN + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'demo-stack'); + // WHEN + const bucket = s3.Bucket.fromBucketAttributes(stack, 'ImportedBucket', { bucketArn: 'arn:aws:s3:::my-bucket' }); + const service = new apprunner.Service(stack, 'DemoService', { + source: apprunner.Source.fromEcrPublic({ + imageIdentifier: 'public.ecr.aws/aws-containers/hello-app-runner:latest', + }), + instanceRole: new iam.Role(stack, 'InstanceRole', { + assumedBy: new iam.ServicePrincipal('tasks.apprunner.amazonaws.com'), + }), + }); + + bucket.grantRead(service); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: [ + 's3:GetObject*', + 's3:GetBucket*', + 's3:List*', + ], + Resource: [ + 'arn:aws:s3:::my-bucket', + 'arn:aws:s3:::my-bucket/*', + ], + }, + ], + }, + PolicyName: 'InstanceRoleDefaultPolicy1531605C', + Roles: [ + { Ref: 'InstanceRole3CCE2F1D' }, + ], + }); +}); + +test('addToRolePolicy', () => { + // GIVEN + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'demo-stack'); + // WHEN + const bucket = s3.Bucket.fromBucketAttributes(stack, 'ImportedBucket', { bucketArn: 'arn:aws:s3:::my-bucket' }); + const service = new apprunner.Service(stack, 'DemoService', { + source: apprunner.Source.fromEcrPublic({ + imageIdentifier: 'public.ecr.aws/aws-containers/hello-app-runner:latest', + }), + }); + + service.addToRolePolicy(new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['s3:GetObject'], + resources: [bucket.bucketArn], + })); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::my-bucket', + }, + ], + }, + PolicyName: 'DemoServiceInstanceRoleDefaultPolicy9600BEA1', + Roles: [ + { Ref: 'DemoServiceInstanceRoleFCED1725' }, + ], + }); }); \ No newline at end of file