diff --git a/packages/deployment-service/cdk/lib/cdk-stack.ts b/packages/deployment-service/cdk/lib/cdk-stack.ts index d827143d9d..bf9190ffb5 100644 --- a/packages/deployment-service/cdk/lib/cdk-stack.ts +++ b/packages/deployment-service/cdk/lib/cdk-stack.ts @@ -31,6 +31,7 @@ import { ResolveProductionS3BucketPermissionsCustomResource } from './resolve-pr import { ResolveProductionOACCustomResource } from './resolve-production-OAC-custom-resource' import { ResolveProductionS3BucketPoliciesCustomResource } from './resolve-production-S3-bucket-policies-custom-resource' import { DnsCertificateUpdate } from './dns-certificate-update' +import { ResolveS3BucketPolicyConditionsCustomResource } from './resolve-s3-bucket-policy-conditions-custom-resource' export const databaseName = 'deployment_service' @@ -311,4 +312,10 @@ export const createStack = async () => { buckets, iaasAccountId: usercodeStack.account, }) + + new ResolveS3BucketPolicyConditionsCustomResource(usercodeStack, 'resolve-s3-bucket-policy-conditions', { + buckets, + iaasAccountId: usercodeStack.account, + paasAcountId: stack.account, + }) } diff --git a/packages/deployment-service/cdk/lib/resolve-s3-bucket-policy-conditions-custom-resource.ts b/packages/deployment-service/cdk/lib/resolve-s3-bucket-policy-conditions-custom-resource.ts new file mode 100644 index 0000000000..83b85cf0a4 --- /dev/null +++ b/packages/deployment-service/cdk/lib/resolve-s3-bucket-policy-conditions-custom-resource.ts @@ -0,0 +1,82 @@ +import { Construct } from 'constructs' +import { Stack, aws_s3, aws_lambda, aws_iam, CustomResource, custom_resources, aws_logs, Duration } from 'aws-cdk-lib' +import { BucketNames } from './create-S3-bucket' + +export class ResolveS3BucketPolicyConditionsCustomResource extends Construct { + constructor( + scope: Stack, + id: string, + { + buckets, + iaasAccountId, + paasAcountId, + }: { buckets: Record; iaasAccountId: string; paasAcountId: string }, + ) { + super(scope, id) + + const envStage = process.env.APP_STAGE === 'production' ? 'prod' : 'dev' + + const liveBucket = aws_s3.Bucket.fromBucketName(this, 'lookup-live-bucket', `${BucketNames.LIVE}-${envStage}`) + const logBucket = aws_s3.Bucket.fromBucketName(this, 'lookup-log-bucket', `${BucketNames.LOG}-${envStage}`) + const repoBucket = aws_s3.Bucket.fromBucketName(this, 'lookup-repo-bucket', `${BucketNames.REPO_CACHE}-${envStage}`) + const versionBucket = aws_s3.Bucket.fromBucketName( + this, + 'lookup-version-bucket', + `${BucketNames.VERSION}-${envStage}`, + ) + + const resolveProductionS3Lambda = new aws_lambda.Function( + scope, + 'resolve-S3-bucket-policy-conditions-custom-resource', + { + handler: 'dist/resolve-s3-bucket-policy-conditions.resolveS3BucketPolicyConditions', + code: aws_lambda.Code.fromAsset('bundle/resolve-s3-bucket-policy-conditions.zip'), + memorySize: 1024, + timeout: Duration.seconds(60), + runtime: aws_lambda.Runtime.NODEJS_18_X, + environment: { + PAAS_ACCOUNT_ID: paasAcountId, + IAAS_ACCOUNT_ID: iaasAccountId, + BUCKETS: [liveBucket.bucketName, logBucket.bucketName, repoBucket.bucketName, versionBucket.bucketName].join( + ',', + ), + }, + }, + ) + + resolveProductionS3Lambda.addToRolePolicy( + new aws_iam.PolicyStatement({ + effect: aws_iam.Effect.ALLOW, + resources: Object.values(buckets).map((bucket) => bucket.bucketArn), + actions: [ + 's3:ListBucket', + 's3:PutObjectAcl', + 's3:GetBucketAcl', + 's3:GetObjectAcl', + 's3:GetBucketLocation', + 's3:GetObjectRetention', + 's3:GetObjectVersionAcl', + 'S3:PutBucketPolicy', + 'S3:GetBucketPolicy', + 's3:DeleteBucketPolicy', + 's3:PutBucketPolicy', + 's3:PutBucketOwnershipControls', + 's3:PutBucketACL', + 's3:PutBucketPublicAccessBlock', + ], + }), + ) + + const resourceProvider = new custom_resources.Provider(scope, 'resolve-S3-bucket-policy-conditions', { + onEventHandler: resolveProductionS3Lambda, + logRetention: aws_logs.RetentionDays.TWO_WEEKS, + }) + + new CustomResource(scope, 'resolve-s3-bucket-policy-conditions-custom-resource', { + serviceToken: resourceProvider.serviceToken, + properties: { + fistonly: true, + }, + }) + } +} diff --git a/packages/deployment-service/package.json b/packages/deployment-service/package.json index f265a6dfa0..67261591f6 100644 --- a/packages/deployment-service/package.json +++ b/packages/deployment-service/package.json @@ -53,7 +53,7 @@ "deploy": "rpt-cdk deploy cdk/cdk.ts", "release": "echo '...skipping...'", "synth": "rpt-cdk synth cdk/cdk.ts", - "bundle": "tsup-zip deployment-service && zip bundle/resolve-production-s3-bucket-policies.zip dist/resolve-production-s3-bucket-policies.js && zip bundle/resolve-production-s3-bucket-permissions.zip dist/resolve-production-s3-bucket-permissions.js && zip bundle/resolve-production-apply-OAC-to-all-distros.zip dist/resolve-production-apply-OAC-to-all-distros.js", + "bundle": "tsup-zip deployment-service && zip bundle/resolve-production-s3-bucket-policies.zip dist/resolve-production-s3-bucket-policies.js && zip bundle/resolve-production-s3-bucket-permissions.zip dist/resolve-production-s3-bucket-permissions.js && zip bundle/resolve-production-apply-OAC-to-all-distros.zip dist/resolve-production-apply-OAC-to-all-distros.js && zip bundle/resolve-s3-bucket-policy-conditions.zip dist/resolve-s3-bucket-policy-conditions.js", "release:watch": "rpt-cdk watch cdk/cdk.ts", "release:destroy": "rpt-cdk destroy cdk/cdk.ts", "publish": "echo '...skipping...'", diff --git a/packages/deployment-service/src/resolve-s3-bucket-policy-conditions.ts b/packages/deployment-service/src/resolve-s3-bucket-policy-conditions.ts new file mode 100644 index 0000000000..28b4a3af47 --- /dev/null +++ b/packages/deployment-service/src/resolve-s3-bucket-policy-conditions.ts @@ -0,0 +1,181 @@ +import { + DeletePublicAccessBlockCommand, + GetBucketPolicyCommand, + PutBucketPolicyCommand, + PutPublicAccessBlockCommand, + S3Client, +} from '@aws-sdk/client-s3' +import { OnEventHandler } from 'aws-cdk-lib/custom-resources/lib/provider-framework/types' + +enum BucketPolicyConditions { + StringEquals = 'StringEquals', +} + +enum BucketPolicyConditionKey { + 'aws:SourceAccount' = 'aws:SourceAccount', + 'aws:PrincipalAccount' = 'aws:PrincipalAccount', +} + +type BucketPolicyCondition = { + [key in BucketPolicyConditions]: { + [key in BucketPolicyConditionKey]?: string | string[] + } +} + +type BucketPolicyStatement = { + Effect: 'Allow' | 'Deny' + Action: string[] + Condition?: BucketPolicyCondition + Resource: string + Principal: { + [s: string]: string + } +} + +const resolveBucketPolicyConditions = + (client: S3Client) => + async ( + bucketName: string, + modifyPolicyStatement: (policyStatement: BucketPolicyStatement) => BucketPolicyStatement, + ) => { + const policyResult = await client.send( + new GetBucketPolicyCommand({ + Bucket: bucketName, + }), + ) + + if (!policyResult.Policy) throw new Error('Policy was not provided') + + const policy: { + Version: string + Statement: BucketPolicyStatement[] + } = JSON.parse(policyResult?.Policy) + + await client.send( + new PutBucketPolicyCommand({ + Bucket: bucketName, + Policy: JSON.stringify({ + ...policy, + Statement: policy.Statement.map((statement) => modifyPolicyStatement(statement)), + }), + }), + ) + } + +const migrateS3BucketPolicyConditions = async ({ + bucketNames, + iaasAccountId, + paasAccountId, +}: { + iaasAccountId: string + paasAccountId: string + bucketNames: string[] +}) => { + const client = new S3Client() + + await Promise.all( + bucketNames.map((bucketName) => + client.send( + new DeletePublicAccessBlockCommand({ + Bucket: bucketName, + }), + ), + ), + ) + + await Promise.all( + bucketNames.map((bucketName) => + resolveBucketPolicyConditions(client)(bucketName, (statement) => ({ + ...statement, + Condition: statement?.Principal?.Service?.includes('cloudfront') + ? undefined + : { + [BucketPolicyConditions.StringEquals]: { + [BucketPolicyConditionKey['aws:PrincipalAccount']]: [paasAccountId, iaasAccountId], + }, + }, + })), + ), + ) + + await Promise.all( + bucketNames.map((bucketName) => + client.send( + new PutPublicAccessBlockCommand({ + Bucket: bucketName, + PublicAccessBlockConfiguration: { + BlockPublicPolicy: true, + BlockPublicAcls: true, + RestrictPublicBuckets: true, + IgnorePublicAcls: true, + }, + }), + ), + ), + ) +} + +const rollbackS3BucketPolicyConditions = async (bucketNames: string[]) => { + const client = new S3Client() + + await Promise.all( + bucketNames.map((bucketName) => + client.send( + new DeletePublicAccessBlockCommand({ + Bucket: bucketName, + }), + ), + ), + ) + + await Promise.all( + bucketNames.map((bucketName) => + resolveBucketPolicyConditions(client)(bucketName, (statement) => { + delete statement.Condition + + return statement + }), + ), + ) + + await Promise.all( + bucketNames.map((bucketName) => + client.send( + new PutPublicAccessBlockCommand({ + Bucket: bucketName, + PublicAccessBlockConfiguration: { + BlockPublicPolicy: true, + BlockPublicAcls: true, + RestrictPublicBuckets: true, + IgnorePublicAcls: true, + }, + }), + ), + ), + ) +} + +export const resolveS3BucketPolicyConditions: OnEventHandler = async (event) => { + const bucketNames = process.env.BUCKETS ? process.env.BUCKETS?.split(',') : [] + + const iaasAccountId = process.env.IAAS_ACCOUNT_ID + const paasAccountId = process.env.PAAS_ACCOUNT_ID + + if (typeof iaasAccountId !== 'string' || typeof paasAccountId !== 'string') + throw new Error('envs for IAAS_ACCOUNT_ID or PAAS_ACCOUNT_ID was not found') + + if (event.RequestType === 'Create') { + await migrateS3BucketPolicyConditions({ + bucketNames, + iaasAccountId, + paasAccountId, + }) + } else if (event.RequestType === 'Delete') { + await rollbackS3BucketPolicyConditions(bucketNames) + } + + return { + PhysicalResourceId: event.PhysicalResourceId, + Data: {}, + } +} diff --git a/packages/deployment-service/tsup.config.js b/packages/deployment-service/tsup.config.js index 3a209e8d11..ff2c9d4666 100644 --- a/packages/deployment-service/tsup.config.js +++ b/packages/deployment-service/tsup.config.js @@ -58,6 +58,7 @@ export default defineConfig([ 'src/resolve-production-s3-bucket-permissions.ts', 'src/resolve-production-s3-bucket-policies.ts', 'src/resolve-production-apply-OAC-to-all-distros.ts', + 'src/resolve-s3-bucket-policy-conditions.ts', ], target: 'node18', clean: true,