Skip to content

Commit

Permalink
Biting the bullet on app runner
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewpatto committed Oct 2, 2023
1 parent 18ef187 commit 2bccacb
Show file tree
Hide file tree
Showing 6 changed files with 370 additions and 426 deletions.
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import {
aws_ec2 as ec2,
aws_ecs as ecs,
CfnOutput,
Stack,
StackProps,
} from "aws-cdk-lib";
import { aws_ec2 as ec2, CfnOutput, Stack } from "aws-cdk-lib";
import { Construct } from "constructs";
import { DockerImageAsset, Platform } from "aws-cdk-lib/aws-ecr-assets";
import {
Policy,
PolicyStatement,
Role,
ServicePrincipal,
} from "aws-cdk-lib/aws-iam";
import { ISecret } from "aws-cdk-lib/aws-secretsmanager";
import { Service } from "aws-cdk-lib/aws-servicediscovery";
import { IHostedZone } from "aws-cdk-lib/aws-route53";
import { ICertificate } from "aws-cdk-lib/aws-certificatemanager";
import { INamespace } from "aws-cdk-lib/aws-servicediscovery";
import { ElsaDataApplicationSettings } from "../elsa-data-application-settings";
import * as apprunner from "@aws-cdk/aws-apprunner-alpha";
import {
addAccessPointStatementsToPolicy,
addBaseStatementsToPolicy,
} from "./elsa-data-application-shared";
import { getPolicyStatementsFromDataBucketPaths } from "../helper/bucket-names-to-policy";
import { ISecurityGroup } from "aws-cdk-lib/aws-ec2";
import { IBucket } from "aws-cdk-lib/aws-s3";
import { ContainerConstruct } from "../construct/container-construct";
import { IHostedZone } from "aws-cdk-lib/aws-route53";
import { ICertificate } from "aws-cdk-lib/aws-certificatemanager";

//
// WIP warning
Expand All @@ -28,91 +28,80 @@ import * as apprunner from "@aws-cdk/aws-apprunner-alpha";
// AND IMPLEMENT HERE
//

interface Props extends StackProps {
vpc: ec2.IVpc;
interface Props extends ElsaDataApplicationSettings {
readonly vpc: ec2.IVpc;

readonly container: ContainerConstruct;

hostedZone: IHostedZone;
hostedZoneCertificate: ICertificate;
readonly cloudMapNamespace: INamespace;

cloudMapService: Service;
// in anticipation of app runner being able to make CNAME mappings automatically - currently not used
readonly hostedZone: IHostedZone;
readonly hostedZoneCertificate: ICertificate;
readonly deployedUrl: string;

/**
* The (passwordless) DSN of our EdgeDb instance as passed to us
* from the EdgeDb stack.
*/
edgeDbDsnNoPassword: string;
// the security group of our edgedb - that we will put ourselves in to enable access
readonly edgeDbSecurityGroup: ISecurityGroup;

/**
* The secret holding the password of our EdgeDb instance.
*/
edgeDbPasswordSecret: ISecret;
// a policy statement that we need to add to our app service in order to give us access to the secrets
readonly accessSecretsPolicyStatement: PolicyStatement;

settings: ElsaDataApplicationSettings;
// a policy statement that we need to add to our app service in order to discover other services via cloud map
readonly discoverServicesPolicyStatement: PolicyStatement;

// an already created temp bucket we can use
readonly tempBucket: IBucket;
}

/**
* The stack for deploying the actual Elsa Data web application via AppRunner.
*/
export class ElsaDataApplicationAppRunnerConstruct extends Construct {
public deployedUrl: string;

constructor(scope: Construct, id: string, props: Props) {
super(scope, id);

this.deployedUrl = `https://${props.settings.urlPrefix}.${props.hostedZone.zoneName}`;

if (!props.settings.buildLocal) return;

const asset = new DockerImageAsset(this, "DockerImageAsset", {
directory: props.settings.buildLocal.folder,
platform: Platform.LINUX_AMD64,
buildArgs: {
ELSA_DATA_BASE_IMAGE: props.settings.imageBaseName,
},
});

const vpcConnector = new apprunner.VpcConnector(this, "VpcConnector", {
vpc: props.vpc,
vpcSubnets: props.vpc.selectSubnets({
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
}),
securityGroups: [props.edgeDbSecurityGroup],
});

const policy = this.createPolicy(
props.settings.awsPermissions.dataBucketPaths,
props.settings.awsPermissions.enableAccessPoints
const policy = new Policy(this, "AppRunnerServiceTaskPolicy");

addBaseStatementsToPolicy(
policy,
Stack.of(this).partition,
Stack.of(this).region,
Stack.of(this).account,
props.accessSecretsPolicyStatement,
props.discoverServicesPolicyStatement,
...getPolicyStatementsFromDataBucketPaths(
Stack.of(this).partition,
props.awsPermissions.dataBucketPaths
)
);

if (props.awsPermissions.enableAccessPoints)
addAccessPointStatementsToPolicy(
policy,
Stack.of(this).partition,
Stack.of(this).region,
Stack.of(this).account
);

const role = new Role(this, "ServiceRole", {
assumedBy: new ServicePrincipal("tasks.apprunner.amazonaws.com"),
});

role.attachInlinePolicy(policy);

// 👇 grant access to bucket
props.tempBucket.grantReadWrite(role);

const appService = new apprunner.Service(this, "Service", {
source: apprunner.Source.fromAsset({
imageConfiguration: {
port: 80,
environmentSecrets: {
EDGEDB_PASSWORD: ecs.Secret.fromSecretsManager(
props.edgeDbPasswordSecret
),
},
environmentVariables: {
EDGEDB_DSN: props.edgeDbDsnNoPassword,
EDGEDB_CLIENT_TLS_SECURITY: "insecure",
ELSA_DATA_META_CONFIG_FOLDERS:
props.settings.metaConfigFolders || "./config",
ELSA_DATA_META_CONFIG_SOURCES: props.settings.metaConfigSources,
// override any config settings that we know definitively here because of the
// way we have done the deployment
ELSA_DATA_CONFIG_DEPLOYED_URL: this.deployedUrl,
ELSA_DATA_CONFIG_PORT: "80",
ELSA_DATA_CONFIG_AWS_TEMP_BUCKET: "tempbucket",
},
},
asset: asset,
}),
source: props.container.appRunnerSource,
instanceRole: role,
autoDeploymentsEnabled: false,
vpcConnector: vpcConnector,
Expand All @@ -122,114 +111,4 @@ export class ElsaDataApplicationAppRunnerConstruct extends Construct {
value: appService.serviceUrl,
});
}

/**
* A policy statement that we can use that gives access only to
* known Elsa Data secrets (by naming convention).
*
* @private
*/
private getSecretPolicyStatement(): PolicyStatement {
return new PolicyStatement({
actions: ["secretsmanager:GetSecretValue"],
resources: [
`arn:aws:secretsmanager:${Stack.of(this).region}:${
Stack.of(this).account
}:secret:ElsaData*`,
],
});
}

private createPolicy(
dataBucketPaths: { [p: string]: string[] },
enableAccessPoints: boolean
): Policy {
const policy = new Policy(this, "FargateServiceTaskPolicy");

// need to be able to fetch secrets - we wildcard to every Secret that has our designated prefix of elsa*
policy.addStatements(this.getSecretPolicyStatement());

// for some of our scaling out work (Beacon etc) - we are going to make Lambdas that we want to be able to invoke
// again we wildcard to a designated prefix of elsa-data*
policy.addStatements(
new PolicyStatement({
actions: ["lambda:InvokeFunction"],
resources: [
`arn:aws:lambda:${Stack.of(this).region}:${
Stack.of(this).account
}:function:elsa-data-*`,
],
})
);

// restrict our Get operations to a very specific set of keys in the named buckets
// NOTE: our 'signing' is always done by a different user so this is not the only
// permission that has to be set correctly
for (const [bucketName, keyWildcards] of Object.entries(dataBucketPaths)) {
policy.addStatements(
new PolicyStatement({
actions: ["s3:GetObject"],
// NOTE: we could consider restricting to region or account here in constructing the ARNS
// but given the bucket names are already globally specific we leave them open
resources: keyWildcards.map((k) => `arn:aws:s3:::${bucketName}/${k}`),
})
);
}

policy.addStatements(
new PolicyStatement({
actions: ["s3:ListBucket"],
resources: Object.keys(dataBucketPaths).map((b) => `arn:aws:s3:::${b}`),
})
);

if (enableAccessPoints) {
policy.addStatements(
// temporarily give all S3 accesspoint perms - can we tighten?
new PolicyStatement({
actions: [
"s3:CreateAccessPoint",
"s3:DeleteAccessPoint",
"s3:DeleteAccessPointPolicy",
"s3:GetAccessPoint",
"s3:GetAccessPointPolicy",
"s3:GetAccessPointPolicyStatus",
"s3:ListAccessPoints",
"s3:PutAccessPointPolicy",
"s3:PutAccessPointPublicAccessBlock",
],
resources: [`*`],
})
);

policy.addStatements(
// access points need the ability to do CloudFormation
// TODO: tighten the policy on the CreateStack as that is a powerful function
// possibly restrict the source of the template url
// possibly restrict the user enacting the CreateStack to only them to create access points
new PolicyStatement({
actions: [
"cloudformation:CreateStack",
"cloudformation:DescribeStacks",
"cloudformation:DeleteStack",
],
resources: [
`arn:aws:cloudformation:${Stack.of(this).region}:${
Stack.of(this).account
}:stack/elsa-data-*`,
],
})
);
}

// for AwsDiscoveryService
policy.addStatements(
new PolicyStatement({
actions: ["servicediscovery:DiscoverInstances"],
resources: ["*"],
})
);

return policy;
}
}
Loading

0 comments on commit 2bccacb

Please sign in to comment.