diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 33ef432..9ca6a7e 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -35,6 +35,11 @@ public sealed class VendorPortalDocumentTests : IDisposable } private VendorPortalController NewController(ApplicationDbContext context) + { + return NewController(NewService(context)); + } + + private VendorPortalService NewService(ApplicationDbContext context) { var vendorData = new VendorDataService(context); var tokenService = new VendorPortalTokenService( @@ -45,11 +50,18 @@ public sealed class VendorPortalDocumentTests : IDisposable storageEnvironment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot); var storage = new VendorDocumentStorageAdapter(storageEnvironment.Object); - var service = new VendorPortalService( + var upliftData = new Mock(); + upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + var commentData = new Mock(); + commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + return new VendorPortalService( tokenService, new DispatchDataService(context), - Mock.Of(), - Mock.Of(), + upliftData.Object, + commentData.Object, Mock.Of(), Mock.Of(), documentData, @@ -57,7 +69,10 @@ public sealed class VendorPortalDocumentTests : IDisposable Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions())); + } + private static VendorPortalController NewController(VendorPortalService service) + { var controller = new VendorPortalController(service, Mock.Of>()); controller.ControllerContext = new ControllerContext { @@ -292,6 +307,173 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().HaveCount(1); } + [Fact] + public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var service = NewService(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + + await NewController(service).UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion-v1.pdf", "application/pdf")); + + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var firstDetail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + + firstDetail.Should().NotBeNull(); + var firstDoc = firstDetail!.Documents.Should().ContainSingle().Subject; + firstDoc.OriginalFileName.Should().Be("completion-v1.pdf"); + firstDoc.ContentType.Should().Be("application/pdf"); + firstDoc.SizeBytes.Should().Be(pdf.Length); + firstDoc.ScanStatus.Should().Be("Pending"); + firstDoc.ReviewStatus.Should().Be("Processing"); + firstDoc.Version.Should().Be(1); + firstDoc.ReplacesDocumentId.Should().BeNull(); + firstDoc.CanDownload.Should().BeFalse(); + firstDoc.CreatedDate.Should().NotBeNull(); + + var first = await context.VendorCompletionDocuments.SingleAsync(); + await NewController(service).UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion-v2.pdf", "application/pdf"), + first.Id); + + var secondDetail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + var ordered = secondDetail!.Documents.OrderByDescending(d => d.Version).ToList(); + ordered.Should().HaveCount(2); + ordered[0].Version.Should().Be(2); + ordered[0].ReplacesDocumentId.Should().Be(first.Id); + ordered[0].OriginalFileName.Should().Be("completion-v2.pdf"); + ordered[1].Version.Should().Be(1); + ordered[1].ReplacesDocumentId.Should().BeNull(); + } + + [Fact] + public async Task GetDispatchDetail_EnablesDownloadOnlyWhenScanPasses() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var service = NewService(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + + await NewController(service).UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion.pdf", "application/pdf")); + + var document = await context.VendorCompletionDocuments.SingleAsync(); + document.ScanStatus = "Passed"; + document.ScannedAt = DateTime.UtcNow; + await context.SaveChangesAsync(); + + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + + var doc = detail!.Documents.Should().ContainSingle().Subject; + doc.ScanStatus.Should().Be("Passed"); + doc.CanDownload.Should().BeTrue(); + doc.ScannedAt.Should().NotBeNull(); + } + + [Theory] + [InlineData("Verified")] + [InlineData("Cancelled")] + public async Task GetDispatchDetail_DisablesDownloadForLockedDispatches(string status) + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var service = NewService(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + + await NewController(service).UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion.pdf", "application/pdf")); + + var document = await context.VendorCompletionDocuments.SingleAsync(); + document.ScanStatus = "Passed"; + dispatch.Status = status; + await context.SaveChangesAsync(); + + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + + detail!.Documents.Should().ContainSingle().Which.CanDownload.Should().BeFalse(); + } + + [Fact] + public async Task GetDispatchDetail_ExcludesSoftDeletedDocuments() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var service = NewService(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + + await NewController(service).UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion.pdf", "application/pdf")); + + var document = await context.VendorCompletionDocuments.SingleAsync(); + document.IsDeleted = true; + await context.SaveChangesAsync(); + + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + + detail!.Documents.Should().BeEmpty(); + } + + [Fact] + public async Task GetDispatchDetail_ReturnsInvoicePaymentFieldsOnlyForOwningVendor() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + dispatch.InvoiceNumber = "INV-2048"; + dispatch.InvoiceStatus = "Confirmed"; + dispatch.PaymentStatus = "Scheduled"; + await context.SaveChangesAsync(); + + var service = NewService(context); + var ownerSession = await service.ResolveSessionAsync(Token, CancellationToken.None); + var ownerDetail = await service.GetDispatchDetailAsync(ownerSession!, dispatch.Id, CancellationToken.None); + + ownerDetail.Should().NotBeNull(); + ownerDetail!.InvoiceNumber.Should().Be("INV-2048"); + ownerDetail.InvoiceStatus.Should().Be("Confirmed"); + ownerDetail.PaymentStatus.Should().Be("Scheduled"); + + var ownerResult = await NewController(service).GetDispatch(dispatch.Id); + var ownerOk = ownerResult.Should().BeOfType().Subject; + var ownerResponse = ownerOk.Value.Should().BeOfType().Subject; + var ownerData = ((object)ownerResponse.Data) + .Should().BeOfType().Subject; + ownerData.InvoiceNumber.Should().Be("INV-2048"); + ownerData.InvoiceStatus.Should().Be("Confirmed"); + ownerData.PaymentStatus.Should().Be("Scheduled"); + + var otherVendor = new Vendor { CompanyName = "Other Vendor", IsActive = true }; + context.Vendors.Add(otherVendor); + await context.SaveChangesAsync(); + const string otherToken = "other-vendor-token"; + context.VendorAccessTokens.Add(new VendorAccessToken + { + VendorId = otherVendor.Id, + Token = otherToken, + IssuedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(1) + }); + await context.SaveChangesAsync(); + + var otherSession = await service.ResolveSessionAsync(otherToken, CancellationToken.None); + var otherDetail = await service.GetDispatchDetailAsync(otherSession!, dispatch.Id, CancellationToken.None); + otherDetail.Should().BeNull(); + + var otherController = NewController(service); + otherController.Request.Headers["X-Vendor-Token"] = otherToken; + var otherResult = await otherController.GetDispatch(dispatch.Id); + otherResult.Should().BeOfType(); + } + public void Dispose() { if (Directory.Exists(_contentRoot)) diff --git a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs index 2ee5192..05aa4e9 100644 --- a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs @@ -30,6 +30,16 @@ namespace SeaHaven.DataServices.Implementation && (document.IsDeleted == null || document.IsDeleted == false), cancellationToken); } + public Task> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken) + { + return _context.VendorCompletionDocuments + .Where(document => document.DispatchId == dispatchId + && document.VendorId == vendorId + && (document.IsDeleted == null || document.IsDeleted == false)) + .OrderByDescending(document => document.Version) + .ToListAsync(cancellationToken); + } + public async Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken) { await _context.VendorCompletionDocuments.AddAsync(document, cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs index 5849b51..d6c76a6 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs @@ -6,6 +6,7 @@ namespace SeaHaven.DataServices.Interfaces { Task GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken); Task GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); + Task> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken); Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken); Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs index 27cc8e7..3a4b5e7 100644 --- a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs @@ -97,6 +97,9 @@ namespace SeaHaven.Services.DTOs public string? DispatchNumber { get; set; } public string? PONumber { get; set; } public string? Status { get; set; } + public string? InvoiceNumber { get; set; } + public string? InvoiceStatus { get; set; } + public string? PaymentStatus { get; set; } public decimal? NTEAmount { get; set; } public string? Description { get; set; } public DateTime? ScheduledDate { get; set; } @@ -109,6 +112,24 @@ namespace SeaHaven.Services.DTOs public IEnumerable Signoffs { get; set; } = Enumerable.Empty(); public IEnumerable Comments { get; set; } = Enumerable.Empty(); public IEnumerable UpliftRequests { get; set; } = Enumerable.Empty(); + public IEnumerable Documents { get; set; } = Enumerable.Empty(); + } + + public class VendorPortalDocumentDTO + { + public int Id { get; set; } + public string? OriginalFileName { get; set; } + public string? ContentType { get; set; } + public long SizeBytes { get; set; } + public string? ScanStatus { get; set; } + public string? ReviewStatus { get; set; } + public string? RejectionReason { get; set; } + public int Version { get; set; } + public int? ReplacesDocumentId { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? ScannedAt { get; set; } + public DateTime? ReviewedAt { get; set; } + public bool CanDownload { get; set; } } public class AcceptDispatchResultDTO diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index b3b2362..d0514c6 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -90,6 +90,7 @@ namespace SeaHaven.Services.Implementation var signoffs = await _dispatchData.GetPortalSignoffsAsync(id, cancellationToken); var rawUplifts = await _upliftData.GetForVendorDispatchAsync(id, cancellationToken); var rawComments = await _commentData.GetVendorViewableForDispatchAsync(id, cancellationToken); + var rawDocuments = await _documentData.ListForVendorDispatchAsync(id, session.Id, cancellationToken); var upliftRequests = rawUplifts.Select(u => new PortalUpliftDTO { @@ -149,6 +150,9 @@ namespace SeaHaven.Services.Implementation DispatchNumber = dispatch.DispatchNumber, PONumber = dispatch.PONumber, Status = dispatch.Status, + InvoiceNumber = dispatch.InvoiceNumber, + InvoiceStatus = dispatch.InvoiceStatus, + PaymentStatus = dispatch.PaymentStatus, NTEAmount = dispatch.NTEAmount, Description = dispatch.Description, ScheduledDate = dispatch.ScheduledDate, @@ -192,7 +196,25 @@ namespace SeaHaven.Services.Implementation SignedAt = s.SignedAt }), Comments = comments, - UpliftRequests = upliftRequests + UpliftRequests = upliftRequests, + Documents = rawDocuments.Select(document => new VendorPortalDocumentDTO + { + Id = document.Id, + OriginalFileName = document.OriginalFileName, + ContentType = document.ContentType, + SizeBytes = document.SizeBytes, + ScanStatus = document.ScanStatus, + ReviewStatus = document.ReviewStatus, + RejectionReason = document.RejectionReason, + Version = document.Version, + ReplacesDocumentId = document.ReplacesDocumentId, + CreatedDate = document.CreatedDate, + ScannedAt = document.ScannedAt, + ReviewedAt = document.ReviewedAt, + CanDownload = string.Equals(document.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase) + && dispatch.Status != "Verified" + && dispatch.Status != "Cancelled" + }) }; } diff --git a/infra/cdk/README.md b/infra/cdk/README.md index 93273f3..e32d030 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -42,13 +42,56 @@ The role grants only: - `elasticbeanstalk:UpdateEnvironment` on environment `shoc-backend-dev` only. - `s3:ListBucket` and `s3:GetBucketLocation` on `elasticbeanstalk-us-east-1-396287094661` (the official action's - ownership-safe bucket checks), plus `s3:CreateBucket` and + ownership-safe bucket checks), `s3:GetBucketPolicy` for the policy inspection + observed in attempt 11 of run `30448885838`, plus `s3:CreateBucket` and `s3:PutBucketOwnershipControls` on the same bucket-level ARN. Under the `shoc-backend/` object prefix only: - `s3:PutObject`, `s3:GetObject`, `s3:GetObjectAcl`, and - `s3:GetObjectVersion`, which the pinned official deployment action and - Elastic Beanstalk require to validate the `CreateApplicationVersion` source - bundle after upload. + `s3:PutObject`, `s3:GetObject`, and `s3:GetObjectVersion`, which the pinned + official deployment action requires to validate the + `CreateApplicationVersion` source bundle after upload. +- `s3:PutObject`, `s3:GetObject`, `s3:GetObjectVersionAcl`, + `s3:PutObjectVersionAcl`, and `s3:DeleteObject` on only + `elasticbeanstalk-us-east-1-396287094661/resources/environments/e-hehnrqjjrt/_runtime/_versions/shoc-backend/*`. + Elastic Beanstalk copies each uploaded source bundle into this + environment-specific runtime prefix during `UpdateEnvironment`, verifies it + with `HeadObject` (authorized by `s3:GetObject`), and removes the temporary + copy after the version is registered. Attempts 1 through 4 of run + `30448885838` exposed the exact source, destination, cleanup, and verification + operations after the earlier ACL denial was resolved. CloudTrail recorded + the exact `s3:GetObject` denial on attempt 4; attempt 6 then exposed the + version-specific ACL read performed on the copied object; attempt 7 exposed + the matching version-ACL write. The grant does not cover another + environment, another application, source bundles, object content versions, + non-version ACL mutation, tags, or retention. +- `s3:PutObject` on only the two embedded-extension prefixes + `elasticbeanstalk-us-east-1-396287094661/resources/_runtime/_embedded_extensions/shoc-backend/*` + and + `elasticbeanstalk-us-east-1-396287094661/resources/environments/e-hehnrqjjrt/_runtime/_embedded_extensions/shoc-backend/*`. + After the runtime bundle copy and version-ACL operations succeeded, attempt 8 + of run `30448885838` showed Elastic Beanstalk materializing the application's + embedded-extension manifest at the application-specific shared prefix. + Attempt 9 then showed the matching write into the exact dev-environment + prefix. CloudTrail recorded both denied actions and object ARNs. The grant + does not include reads, deletes, ACL mutation, another application, + another environment, or another bucket. +- `s3:GetObject` on only the environment-specific embedded-extension prefix + above. Attempt 10 showed that Elastic Beanstalk verifies the materialized + environment copy with `HeadObject`, which S3 authorizes through + `s3:GetObject`. The shared embedded-extension prefix remains write-only. +- `s3:GetObject` and `s3:PutObject` on only + `elasticbeanstalk-us-east-1-396287094661/resources/environments/e-hehnrqjjrt/_runtime/versions/*`. + Attempt 12 showed Elastic Beanstalk reading the previous environment version + manifest and writing its replacement under this exact dev-environment + runtime prefix. The grant excludes deletes, ACL mutation, other environments, + and application bundle content. +- `s3:GetObjectAcl` on objects under the service-wide + `arn:aws:s3:::elasticbeanstalk-*/*` namespace. AWS Support case + `178526484500047` confirmed that `UpdateEnvironment` uses the initiating + role to inspect objects in AWS-owned Elastic Beanstalk buckets, not only the + account-owned source-bundle bucket. The wildcard is limited to one read-only + ACL action and the Elastic Beanstalk bucket namespace; it grants no object + content read, write, delete, bucket-management, IAM, or `PassRole` + capability. `s3:CreateBucket` is part of the pinned `aws-actions/aws-elastic-beanstalk-deploy@cfad3e5e...` (v1.0.6) IAM @@ -112,15 +155,11 @@ The role grants only: successful administrator deployment. AWS supports resource-level constraints for all three mutations, so replacement ASGs remain covered without granting access to another environment. -- `s3:GetObjectAcl` under the existing - `elasticbeanstalk-us-east-1-396287094661/shoc-backend/*` object prefix. - Elastic Beanstalk emitted this read during the same attempt while validating - the uploaded application bundle. It grants no bucket-wide or cross-prefix - object access. It grants **no** IAM mutation or `PassRole`, **no** RDS / Secrets Manager access, no EC2 mutation, and **no** administrator policy. The only non-EB/S3 mutations are the three deployment-process Auto Scaling calls, restricted to -this environment's ASG name pattern. There are no wildcard mutation surfaces. +this environment's ASG name pattern. There are no wildcard mutation surfaces; +the only service-wide object grant is read-only ACL metadata. ## Prerequisites @@ -160,13 +199,16 @@ All commands run from `infra/cdk/`. The workflow's `AWS_DEPLOY_ROLE_ARN` repository secret (environment `dev`) must hold the ARN output by this stack (`GithubDeployRoleArn`). -The OIDC deployment is currently fail-closed, not repaired. Elastic Beanstalk -still reports a generic `s3:GetObjectAcl` denial after the account-owned source -prefix and every exact cross-account object referenced by the sanitized live -stack were tested independently. The runtime-prefix, platform-assets, and -launch-control hypotheses were disproved and are intentionally absent from the -policy. Do not broaden `GetObjectAcl` without an exact principal/action/resource -record from AWS Support or the AWS-owned bucket's diagnostic owner. +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. 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 3575452..0cdef0c 100644 --- a/infra/cdk/deploy-dev-stack.ts +++ b/infra/cdk/deploy-dev-stack.ts @@ -18,6 +18,18 @@ 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', { @@ -134,6 +146,7 @@ export class DeployDevStack extends cdk.Stack { 's3:CreateBucket', 's3:PutBucketOwnershipControls', 's3:GetBucketLocation', + 's3:GetBucketPolicy', ], resources: [bucketArn], }), @@ -142,11 +155,68 @@ export class DeployDevStack extends cdk.Stack { deployRole.addToPolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, - actions: ['s3:GetObject', 's3:GetObjectAcl', 's3:GetObjectVersion', 's3:PutObject'], + 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. + 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.',