mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Merge branch 'dev' into fix/sh133-webhook-secret-config
This commit is contained in:
commit
48f61afe49
7 changed files with 371 additions and 23 deletions
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue