diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24167e5..335b87c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,34 @@ -name: CI +name: Backend CI + on: pull_request: branches: [main, dev] + +permissions: + contents: read + jobs: - ci: - uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@main - with: - dotnet-version: "8.0.x" - solution: "SeaHavenIndustries.sln" - run-tests: true + build-and-test: + name: Build and test + runs-on: ubuntu-latest + timeout-minutes: 20 + concurrency: + group: backend-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: "8.0.x" + + - name: Restore + run: dotnet restore SeaHavenIndustries.sln + + - name: Build + run: dotnet build SeaHavenIndustries.sln --no-restore --configuration Release + + - name: Test + run: dotnet test SeaHavenIndustries.sln --no-build --configuration Release diff --git a/infra/cdk/README.md b/infra/cdk/README.md index e32d030..d4dab94 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -191,10 +191,11 @@ All commands run from `infra/cdk/`. - Trust policy `StringEquals` matches the exact audience and subject above. - The inline policy contains no `Resource: "*"` mutation action and no service outside `elasticbeanstalk` / `s3` / `cloudformation` / `ec2` / - `autoscaling`. CloudFormation discovery is limited to the single EB-managed - stack prefix. EC2 and Auto Scaling discovery use `Resource: "*"` only where - the IAM resource model requires it; Auto Scaling mutations are limited to - this environment's ASG name pattern. + `elasticloadbalancing` / `autoscaling`. CloudFormation discovery and + mutations are limited to the single EB-managed stack prefix. EC2, Elastic + Load Balancing, and Auto Scaling discovery use `Resource: "*"` only where the + IAM resource model requires it; Auto Scaling mutations are limited to this + environment's ASG name pattern. The workflow's `AWS_DEPLOY_ROLE_ARN` repository secret (environment `dev`) must hold the ARN output by this stack (`GithubDeployRoleArn`). @@ -203,12 +204,46 @@ The previous OIDC deployment remained fail-closed after Elastic Beanstalk reported a generic `s3:GetObjectAcl` denial outside the account-owned source prefix. AWS Support case `178526484500047` subsequently confirmed that `UpdateEnvironment` checks objects in AWS-owned Elastic Beanstalk buckets -using the initiating role and requires the `elasticbeanstalk-*/*` resource -namespace. This policy adds only the denied ACL-read action on that namespace; -it intentionally does not copy the managed -`AdministratorAccess-AWSElasticBeanstalk` policy's broad `s3:Get*`, -`s3:Put*`, or `s3:Delete*` grants. Any later denial must be evaluated and -granted independently. +using the initiating role and requires the service-wide +`elasticbeanstalk-*` bucket/object namespaces. + +The July 30 deployment of backend PR #41 then reached `UpdateEnvironment` and +failed on `ec2:DescribeVpcs`. Elastic Beanstalk performs this read-only network +discovery using the initiating role, so the CDK policy includes that action +alongside the existing EC2 describe permissions. It remains resource `*` +because `DescribeVpcs` does not support resource-level permissions. + +Successive exact reruns then reached S3 cleanup, the delegated CloudFormation +update, and the CloudFormation template fetch. The observed failures were +`s3:DeleteObject`, `cloudformation:UpdateStack`, and finally an opaque +CloudFormation `S3 error: Access Denied` after narrower object reads had been +added. Because AWS does not expose the AWS-owned bucket/key or exact internal +S3 read in that final error, the CDK now uses AWS Support's authoritative +UpdateEnvironment S3 set: + +- `s3:Delete*`, `s3:Get*`, and `s3:Put*` on + `arn:aws:s3:::elasticbeanstalk-*/*`. +- `s3:GetBucket*`, `s3:ListBucket`, `s3:PutBucketPolicy`, + `s3:PutBucketPublicAccessBlock`, and `s3:PutBucketOwnershipControls` on + `arn:aws:s3:::elasticbeanstalk-*`. + +`s3:CreateBucket` remains excluded because this workflow targets an existing +application/environment and explicitly disables bucket creation. No S3 access +is granted to non-Elastic-Beanstalk bucket names. The CloudFormation mutation +remains limited to the single existing `shoc-backend-dev` managed stack ARN; it +cannot create stacks or update another stack. + +The next rerun cleared S3 and then required the read-only +`elasticloadbalancing:DescribeLoadBalancers` discovery action. Its failed +managed-stack update also required `cloudformation:CancelUpdateStack`; the +cancel action is scoped to the same single stack ARN as `UpdateStack`. + +The subsequent rerun progressed into Auto Scaling and required +`autoscaling:DescribeLaunchConfigurations`. Because Elastic Beanstalk's +managed update workflow performs variable resource discovery, the role follows +the documented read-only discovery families for EC2, Elastic Load Balancing, +and Auto Scaling (`Describe*`). These grants expose metadata across the account +but do not authorize any mutation; write actions remain separately scoped. The pinned deployment action can return success after Elastic Beanstalk emits a fatal deployment event. The following workflow step therefore verifies that diff --git a/infra/cdk/deploy-dev-stack.ts b/infra/cdk/deploy-dev-stack.ts index 0cdef0c..6ec101d 100644 --- a/infra/cdk/deploy-dev-stack.ts +++ b/infra/cdk/deploy-dev-stack.ts @@ -9,7 +9,6 @@ const ENVIRONMENT_NAME = 'shoc-backend-dev'; const ENVIRONMENT_ID = 'e-hehnrqjjrt'; const ENVIRONMENT_STACK_NAME = `awseb-${ENVIRONMENT_ID}-stack`; const REPO = 'Sea-Haven-Industries/shoc-backend'; -const BUCKET_NAME = `elasticbeanstalk-${REGION}-${ACCOUNT_ID}`; export class DeployDevStack extends cdk.Stack { constructor(scope: Construct, id: string, props: cdk.StackProps = {}) { @@ -17,19 +16,6 @@ export class DeployDevStack extends cdk.Stack { const applicationArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:application/${APPLICATION_NAME}`; const environmentArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:environment/${APPLICATION_NAME}/${ENVIRONMENT_NAME}`; - const bucketArn = `arn:aws:s3:::${BUCKET_NAME}`; - const runtimeVersionArn = - `${bucketArn}/resources/environments/${ENVIRONMENT_ID}` + - `/_runtime/_versions/${APPLICATION_NAME}/*`; - const embeddedExtensionArn = - `${bucketArn}/resources/_runtime/_embedded_extensions/` + - `${APPLICATION_NAME}/*`; - const environmentEmbeddedExtensionArn = - `${bucketArn}/resources/environments/${ENVIRONMENT_ID}` + - `/_runtime/_embedded_extensions/${APPLICATION_NAME}/*`; - const runtimeManifestArn = - `${bucketArn}/resources/environments/${ENVIRONMENT_ID}` + - '/_runtime/versions/*'; const oidcProviderArn = `arn:aws:iam::${ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com`; const deployRole = new iam.Role(this, 'GithubDeployRole', { @@ -57,9 +43,12 @@ export class DeployDevStack extends cdk.Stack { new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ + 'autoscaling:Describe*', + 'ec2:Describe*', 'elasticbeanstalk:DescribeEnvironments', 'elasticbeanstalk:DescribeApplicationVersions', 'elasticbeanstalk:DescribeEvents', + 'elasticloadbalancing:Describe*', ], resources: ['*'], }), @@ -94,6 +83,8 @@ export class DeployDevStack extends cdk.Stack { 'cloudformation:DescribeStackResources', 'cloudformation:DescribeStacks', 'cloudformation:ListStackResources', + 'cloudformation:CancelUpdateStack', + 'cloudformation:UpdateStack', ], resources: [ `arn:aws:cloudformation:${REGION}:${ACCOUNT_ID}:stack/${ENVIRONMENT_STACK_NAME}/*`, @@ -101,29 +92,6 @@ export class DeployDevStack extends cdk.Stack { }), ); - deployRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: [ - 'ec2:DescribeAvailabilityZones', - 'ec2:DescribeImages', - 'ec2:DescribeSubnets', - ], - resources: ['*'], - }), - ); - - deployRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: [ - 'autoscaling:DescribeAutoScalingGroups', - 'autoscaling:DescribeScalingActivities', - ], - resources: ['*'], - }), - ); - deployRole.addToPolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, @@ -141,82 +109,31 @@ export class DeployDevStack extends cdk.Stack { deployRole.addToPolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, - actions: [ - 's3:ListBucket', - 's3:CreateBucket', - 's3:PutBucketOwnershipControls', - 's3:GetBucketLocation', - 's3:GetBucketPolicy', - ], - resources: [bucketArn], - }), - ); - - deployRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: ['s3:GetObject', 's3:GetObjectVersion', 's3:PutObject'], - resources: [`${bucketArn}/${APPLICATION_NAME}/*`], - }), - ); - - deployRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: [ - 's3:DeleteObject', - 's3:GetObject', - 's3:GetObjectVersionAcl', - 's3:PutObject', - 's3:PutObjectVersionAcl', - ], - // UpdateEnvironment copies the uploaded source bundle into this - // environment-specific runtime prefix, verifies the temporary copy, - // preserves its version ACL, and removes it after registration. - resources: [runtimeVersionArn], - }), - ); - - deployRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: ['s3:PutObject'], - // UpdateEnvironment materializes the application's embedded-extension - // manifest under the shared and environment-specific runtime prefixes. - resources: [embeddedExtensionArn, environmentEmbeddedExtensionArn], - }), - ); - - deployRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: ['s3:GetObject'], - // Elastic Beanstalk verifies the environment copy with HeadObject. - resources: [environmentEmbeddedExtensionArn], - }), - ); - - deployRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: ['s3:GetObject', 's3:PutObject'], - // UpdateEnvironment reads the prior environment version manifest and - // writes its replacement under this environment-only runtime prefix. - resources: [runtimeManifestArn], - }), - ); - - deployRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: ['s3:GetObjectAcl'], - // UpdateEnvironment also checks objects in AWS-owned Elastic Beanstalk - // buckets. AWS Support case 178526484500047 confirmed that the caller's - // identity policy must cover the service-wide bucket namespace. + actions: ['s3:Delete*', 's3:Get*', 's3:Put*'], + // AWS Support case 178526484500047 confirmed that UpdateEnvironment + // reads, writes, versions, ACL-checks, and removes objects in both the + // account bucket and AWS-owned Elastic Beanstalk service buckets. resources: ['arn:aws:s3:::elasticbeanstalk-*/*'], }), ); + deployRole.addToPolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + 's3:GetBucket*', + 's3:ListBucket', + 's3:PutBucketOwnershipControls', + 's3:PutBucketPolicy', + 's3:PutBucketPublicAccessBlock', + ], + // This is AWS Support's bucket-level UpdateEnvironment set, excluding + // CreateBucket because the workflow deploys only to an existing + // application/environment and disables bucket creation. + resources: ['arn:aws:s3:::elasticbeanstalk-*'], + }), + ); + new cdk.CfnOutput(this, 'GithubDeployRoleArn', { value: deployRole.roleArn, description: 'ARN of the GitHub OIDC deploy role for shoc-backend dev.',