Merge branch 'dev' into fix/sh133-webhook-secret-config

This commit is contained in:
Adam Moussa 2026-07-30 10:26:08 -04:00 • committed by GitHub
commit 48f61afe49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 371 additions and 23 deletions

View file

@ -35,6 +35,11 @@ public sealed class VendorPortalDocumentTests : IDisposable
} }
private VendorPortalController NewController(ApplicationDbContext context) private VendorPortalController NewController(ApplicationDbContext context)
{
return NewController(NewService(context));
}
private VendorPortalService NewService(ApplicationDbContext context)
{ {
var vendorData = new VendorDataService(context); var vendorData = new VendorDataService(context);
var tokenService = new VendorPortalTokenService( var tokenService = new VendorPortalTokenService(
@ -45,11 +50,18 @@ public sealed class VendorPortalDocumentTests : IDisposable
storageEnvironment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot); storageEnvironment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot);
var storage = new VendorDocumentStorageAdapter(storageEnvironment.Object); var storage = new VendorDocumentStorageAdapter(storageEnvironment.Object);
var service = new VendorPortalService( var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalUpliftData>());
var commentData = new Mock<ICommentDataService>();
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalCommentData>());
return new VendorPortalService(
tokenService, tokenService,
new DispatchDataService(context), new DispatchDataService(context),
Mock.Of<IUpliftDataService>(), upliftData.Object,
Mock.Of<ICommentDataService>(), commentData.Object,
Mock.Of<IUserDataService>(), Mock.Of<IUserDataService>(),
Mock.Of<IEmailSender>(), Mock.Of<IEmailSender>(),
documentData, documentData,
@ -57,7 +69,10 @@ public sealed class VendorPortalDocumentTests : IDisposable
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions())); Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()));
}
private static VendorPortalController NewController(VendorPortalService service)
{
var controller = new VendorPortalController(service, Mock.Of<ILogger<VendorPortalController>>()); var controller = new VendorPortalController(service, Mock.Of<ILogger<VendorPortalController>>());
controller.ControllerContext = new ControllerContext controller.ControllerContext = new ControllerContext
{ {
@ -292,6 +307,173 @@ public sealed class VendorPortalDocumentTests : IDisposable
context.VendorCompletionDocuments.Should().HaveCount(1); 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<OkObjectResult>().Subject;
var ownerResponse = ownerOk.Value.Should().BeOfType<DataResponse>().Subject;
var ownerData = ((object)ownerResponse.Data)
.Should().BeOfType<SeaHaven.Services.DTOs.VendorDispatchDetailDTO>().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<NotFoundObjectResult>();
}
public void Dispose() public void Dispose()
{ {
if (Directory.Exists(_contentRoot)) if (Directory.Exists(_contentRoot))

View file

@ -30,6 +30,16 @@ namespace SeaHaven.DataServices.Implementation
&& (document.IsDeleted == null || document.IsDeleted == false), cancellationToken); && (document.IsDeleted == null || document.IsDeleted == false), cancellationToken);
} }
public Task<List<VendorCompletionDocument>> 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) public async Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken)
{ {
await _context.VendorCompletionDocuments.AddAsync(document, cancellationToken); await _context.VendorCompletionDocuments.AddAsync(document, cancellationToken);

View file

@ -6,6 +6,7 @@ namespace SeaHaven.DataServices.Interfaces
{ {
Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken); Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<VendorCompletionDocument?> GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); Task<VendorCompletionDocument?> GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
Task<List<VendorCompletionDocument>> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken);
Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken); Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken); Task SaveChangesAsync(CancellationToken cancellationToken);
} }

View file

@ -97,6 +97,9 @@ namespace SeaHaven.Services.DTOs
public string? DispatchNumber { get; set; } public string? DispatchNumber { get; set; }
public string? PONumber { get; set; } public string? PONumber { get; set; }
public string? Status { 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 decimal? NTEAmount { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
public DateTime? ScheduledDate { get; set; } public DateTime? ScheduledDate { get; set; }
@ -109,6 +112,24 @@ namespace SeaHaven.Services.DTOs
public IEnumerable<PortalSignoffDTO> Signoffs { get; set; } = Enumerable.Empty<PortalSignoffDTO>(); public IEnumerable<PortalSignoffDTO> Signoffs { get; set; } = Enumerable.Empty<PortalSignoffDTO>();
public IEnumerable<PortalCommentDTO> Comments { get; set; } = Enumerable.Empty<PortalCommentDTO>(); public IEnumerable<PortalCommentDTO> Comments { get; set; } = Enumerable.Empty<PortalCommentDTO>();
public IEnumerable<PortalUpliftDTO> UpliftRequests { get; set; } = Enumerable.Empty<PortalUpliftDTO>(); public IEnumerable<PortalUpliftDTO> UpliftRequests { get; set; } = Enumerable.Empty<PortalUpliftDTO>();
public IEnumerable<VendorPortalDocumentDTO> Documents { get; set; } = Enumerable.Empty<VendorPortalDocumentDTO>();
}
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 public class AcceptDispatchResultDTO

View file

@ -90,6 +90,7 @@ namespace SeaHaven.Services.Implementation
var signoffs = await _dispatchData.GetPortalSignoffsAsync(id, cancellationToken); var signoffs = await _dispatchData.GetPortalSignoffsAsync(id, cancellationToken);
var rawUplifts = await _upliftData.GetForVendorDispatchAsync(id, cancellationToken); var rawUplifts = await _upliftData.GetForVendorDispatchAsync(id, cancellationToken);
var rawComments = await _commentData.GetVendorViewableForDispatchAsync(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 var upliftRequests = rawUplifts.Select(u => new PortalUpliftDTO
{ {
@ -149,6 +150,9 @@ namespace SeaHaven.Services.Implementation
DispatchNumber = dispatch.DispatchNumber, DispatchNumber = dispatch.DispatchNumber,
PONumber = dispatch.PONumber, PONumber = dispatch.PONumber,
Status = dispatch.Status, Status = dispatch.Status,
InvoiceNumber = dispatch.InvoiceNumber,
InvoiceStatus = dispatch.InvoiceStatus,
PaymentStatus = dispatch.PaymentStatus,
NTEAmount = dispatch.NTEAmount, NTEAmount = dispatch.NTEAmount,
Description = dispatch.Description, Description = dispatch.Description,
ScheduledDate = dispatch.ScheduledDate, ScheduledDate = dispatch.ScheduledDate,
@ -192,7 +196,25 @@ namespace SeaHaven.Services.Implementation
SignedAt = s.SignedAt SignedAt = s.SignedAt
}), }),
Comments = comments, 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"
})
}; };
} }

View file

@ -42,13 +42,56 @@ The role grants only:
- `elasticbeanstalk:UpdateEnvironment` on environment `shoc-backend-dev` only. - `elasticbeanstalk:UpdateEnvironment` on environment `shoc-backend-dev` only.
- `s3:ListBucket` and `s3:GetBucketLocation` on - `s3:ListBucket` and `s3:GetBucketLocation` on
`elasticbeanstalk-us-east-1-396287094661` (the official action's `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 `s3:PutBucketOwnershipControls` on the same bucket-level ARN. Under the
`shoc-backend/` object prefix only: `shoc-backend/` object prefix only:
`s3:PutObject`, `s3:GetObject`, `s3:GetObjectAcl`, and `s3:PutObject`, `s3:GetObject`, and `s3:GetObjectVersion`, which the pinned
`s3:GetObjectVersion`, which the pinned official deployment action and official deployment action requires to validate the
Elastic Beanstalk require to validate the `CreateApplicationVersion` source `CreateApplicationVersion` source bundle after upload.
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 `s3:CreateBucket` is part of the pinned
`aws-actions/aws-elastic-beanstalk-deploy@cfad3e5e...` (v1.0.6) IAM `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 successful administrator deployment. AWS supports resource-level
constraints for all three mutations, so replacement ASGs remain covered constraints for all three mutations, so replacement ASGs remain covered
without granting access to another environment. 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 It grants **no** IAM mutation or `PassRole`, **no** RDS / Secrets Manager
access, no EC2 mutation, and **no** administrator policy. The only non-EB/S3 access, no EC2 mutation, and **no** administrator policy. The only non-EB/S3
mutations are the three deployment-process Auto Scaling calls, restricted to 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 ## Prerequisites
@ -160,13 +199,16 @@ All commands run from `infra/cdk/`.
The workflow's `AWS_DEPLOY_ROLE_ARN` repository secret (environment `dev`) must The workflow's `AWS_DEPLOY_ROLE_ARN` repository secret (environment `dev`) must
hold the ARN output by this stack (`GithubDeployRoleArn`). hold the ARN output by this stack (`GithubDeployRoleArn`).
The OIDC deployment is currently fail-closed, not repaired. Elastic Beanstalk The previous OIDC deployment remained fail-closed after Elastic Beanstalk
still reports a generic `s3:GetObjectAcl` denial after the account-owned source reported a generic `s3:GetObjectAcl` denial outside the account-owned source
prefix and every exact cross-account object referenced by the sanitized live prefix. AWS Support case `178526484500047` subsequently confirmed that
stack were tested independently. The runtime-prefix, platform-assets, and `UpdateEnvironment` checks objects in AWS-owned Elastic Beanstalk buckets
launch-control hypotheses were disproved and are intentionally absent from the using the initiating role and requires the `elasticbeanstalk-*/*` resource
policy. Do not broaden `GetObjectAcl` without an exact principal/action/resource namespace. This policy adds only the denied ACL-read action on that namespace;
record from AWS Support or the AWS-owned bucket's diagnostic owner. 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 The pinned deployment action can return success after Elastic Beanstalk emits a
fatal deployment event. The following workflow step therefore verifies that fatal deployment event. The following workflow step therefore verifies that

View file

@ -18,6 +18,18 @@ export class DeployDevStack extends cdk.Stack {
const applicationArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:application/${APPLICATION_NAME}`; 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 environmentArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:environment/${APPLICATION_NAME}/${ENVIRONMENT_NAME}`;
const bucketArn = `arn:aws:s3:::${BUCKET_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 oidcProviderArn = `arn:aws:iam::${ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com`;
const deployRole = new iam.Role(this, 'GithubDeployRole', { const deployRole = new iam.Role(this, 'GithubDeployRole', {
@ -134,6 +146,7 @@ export class DeployDevStack extends cdk.Stack {
's3:CreateBucket', 's3:CreateBucket',
's3:PutBucketOwnershipControls', 's3:PutBucketOwnershipControls',
's3:GetBucketLocation', 's3:GetBucketLocation',
's3:GetBucketPolicy',
], ],
resources: [bucketArn], resources: [bucketArn],
}), }),
@ -142,11 +155,68 @@ export class DeployDevStack extends cdk.Stack {
deployRole.addToPolicy( deployRole.addToPolicy(
new iam.PolicyStatement({ new iam.PolicyStatement({
effect: iam.Effect.ALLOW, effect: iam.Effect.ALLOW,
actions: ['s3:GetObject', 's3:GetObjectAcl', 's3:GetObjectVersion', 's3:PutObject'], actions: ['s3:GetObject', 's3:GetObjectVersion', 's3:PutObject'],
resources: [`${bucketArn}/${APPLICATION_NAME}/*`], 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', { new cdk.CfnOutput(this, 'GithubDeployRoleArn', {
value: deployRole.roleArn, value: deployRole.roleArn,
description: 'ARN of the GitHub OIDC deploy role for shoc-backend dev.', description: 'ARN of the GitHub OIDC deploy role for shoc-backend dev.',