diff --git a/docs/planning/staff-owner-identity-bootstrap-hardening-task.md b/docs/planning/staff-owner-identity-bootstrap-hardening-task.md index 75d49ae9..0a404fd1 100644 --- a/docs/planning/staff-owner-identity-bootstrap-hardening-task.md +++ b/docs/planning/staff-owner-identity-bootstrap-hardening-task.md @@ -49,9 +49,11 @@ independent from Organizations membership lifecycle. - Any existing Staff identity whose Auth-subject binding remains available, including suspended, departed, or restricted data, makes bootstrap a successful no-op. -- Anonymisation erases the Auth-subject binding. Staff cannot correlate a later - operation by that erased value; current Organizations access admission is the - stale-event fence before the bootstrap capability is invoked. +- Anonymisation erases the Auth-subject binding. Current Organizations access + admission blocks stale events after organization or membership access is + removed, but it cannot correlate a later, different event while that subject + is still authorized. Durable source correlation across erased bindings remains + a release follow-up before this bootstrap is production-admitted. - Bootstrap never updates profile fields and never advances an existing Staff version. @@ -88,14 +90,18 @@ independent from Organizations membership lifecycle. - Bootstrap serializes the source operation, uses safety-visible identity lookup, and creates a Staff member only when neither the operation id nor Auth subject already exists. Existing, suspended, departed, and restricted identities with - an available Auth binding remain untouched; Organizations admission protects - the erased-binding anonymisation boundary from stale membership events. + an available Auth binding remain untouched. Organizations admission protects + the erased-binding boundary from removed or inactive membership events, but + does not prevent a later still-authorized event from reaching Staff after the + Auth-subject binding was erased. - Exact replay and competing source operations converge through the existing transaction lock, scoped Auth-subject uniqueness, and persistence retry pipeline. No new receipt table or migration was required. -- The Staff personal-data catalog, generated inventory, data-rights export, and - tenant-termination manifest now agree on catalog version 16, with a regression - assertion preventing future version drift. +- At this slice boundary, the Staff personal-data catalog, generated inventory, + data-rights export, and tenant-termination manifest agreed on catalog version + 16. That evidence is historical: subsequent Staff onboarding and self-service + profile contract work advanced the current personal-data catalog to version + 18, which is the version current admission evidence must use. - GMA required no change because Organizations already owns the authoritative access reader and the framework already supplies the required transactional lock and retry primitives. @@ -114,5 +120,8 @@ independent from Organizations membership lifecycle. ## Deferred +- Durable bootstrap source correlation across Staff Auth-subject anonymisation + remains a release follow-up; current access admission alone cannot identify a + later still-authorized source event as referring to the erased identity. - Public multi-account invitation, QR/link, provider redirect, broker delivery, and process-restart evidence remains the workspace-onboarding deployment gate. diff --git a/src/BunkFy.Host.AdminApi/appsettings.json b/src/BunkFy.Host.AdminApi/appsettings.json index f8c10dd1..6dc56ace 100644 --- a/src/BunkFy.Host.AdminApi/appsettings.json +++ b/src/BunkFy.Host.AdminApi/appsettings.json @@ -294,7 +294,7 @@ "Api": { "ActorIdClaim": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", "TenantIdClaim": "scope_id", - "RequireTenantClaimMatch": true, + "RequireTenantClaimMatch": false, "AllowGeneratedPasswordResponses": false }, "Audit": { diff --git a/src/Extensions/BunkFy.Extensions.Operations.Notifications/OperationsNotificationsStaffHistoryPolicyEvidence.cs b/src/Extensions/BunkFy.Extensions.Operations.Notifications/OperationsNotificationsStaffHistoryPolicyEvidence.cs index 52d081fe..60e20500 100644 --- a/src/Extensions/BunkFy.Extensions.Operations.Notifications/OperationsNotificationsStaffHistoryPolicyEvidence.cs +++ b/src/Extensions/BunkFy.Extensions.Operations.Notifications/OperationsNotificationsStaffHistoryPolicyEvidence.cs @@ -68,7 +68,7 @@ public static string ComputeSnapshotSha256( "bunkfy-operations-notifications-staff-history-snapshot/v1", reference.Namespace, reference.Digest, - ((int)snapshot.Status).ToString( + V1StatusCode(snapshot.Status).ToString( CultureInfo.InvariantCulture), snapshot.Version.ToString(CultureInfo.InvariantCulture), snapshot.RecordCount.ToString(CultureInfo.InvariantCulture), @@ -77,4 +77,14 @@ public static string ComputeSnapshotSha256( return Convert.ToHexStringLower( SHA256.HashData(Encoding.UTF8.GetBytes(canonical))); } + + private static int V1StatusCode(NotificationHistoryReferenceStatus status) => + status switch + { + NotificationHistoryReferenceStatus.Missing => 0, + NotificationHistoryReferenceStatus.Open => 1, + NotificationHistoryReferenceStatus.Closed => 2, + _ => throw new InvalidOperationException( + "The notification history status has no v1 evidence code.") + }; } diff --git a/src/Extensions/tests/BunkFy.Extensions.Operations.Notifications.Tests/OperationsNotificationsStaffDataRightsTests.cs b/src/Extensions/tests/BunkFy.Extensions.Operations.Notifications.Tests/OperationsNotificationsStaffDataRightsTests.cs index ccb2232f..e4cef38c 100644 --- a/src/Extensions/tests/BunkFy.Extensions.Operations.Notifications.Tests/OperationsNotificationsStaffDataRightsTests.cs +++ b/src/Extensions/tests/BunkFy.Extensions.Operations.Notifications.Tests/OperationsNotificationsStaffDataRightsTests.cs @@ -24,6 +24,52 @@ public sealed class OperationsNotificationsStaffDataRightsTests private static readonly Guid PropertyId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + [Theory] + [InlineData( + NotificationHistoryReferenceStatus.Missing, + "59d846f9695b706747d28866592a8a51bac74751cdf3105e00cb4d762c94ae34")] + [InlineData( + NotificationHistoryReferenceStatus.Open, + "bb172b8a7e3021604ebb79558dfc962c2d8858e58f6a69a213677ddfd3498320")] + [InlineData( + NotificationHistoryReferenceStatus.Closed, + "93cc5fc92a0a7235aaf8b8f13cf7fdb29ef94acf69de1bb05f5d70a66eea9beb")] + public void Snapshot_hash_preserves_all_v1_status_codes( + NotificationHistoryReferenceStatus status, + string expectedSha256) + { + NotificationHistoryReference reference = new( + "staff", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + NotificationHistoryReferenceSnapshot snapshot = new( + status, + 3, + 2, + 9); + + Assert.Equal( + expectedSha256, + OperationsNotificationsStaffHistoryPolicyEvidence + .ComputeSnapshotSha256(reference, snapshot)); + } + + [Fact] + public void Snapshot_hash_rejects_statuses_without_a_v1_evidence_code() + { + NotificationHistoryReference reference = new( + "staff", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + NotificationHistoryReferenceSnapshot snapshot = new( + NotificationHistoryReferenceStatus.Unknown, + 3, + 2, + 9); + + Assert.Throws(() => + OperationsNotificationsStaffHistoryPolicyEvidence + .ComputeSnapshotSha256(reference, snapshot)); + } + [Fact] public async Task Policy_binds_open_history_to_exact_departed_staff_authority() { diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationsInboxDomainEventDispatchTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationsInboxDomainEventDispatchTests.cs new file mode 100644 index 00000000..68f137ab --- /dev/null +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationsInboxDomainEventDispatchTests.cs @@ -0,0 +1,364 @@ +namespace BunkFy.Modules.Reservations.Tests.Persistence; + +using BunkFy.Modules.Guests.Contracts; +using BunkFy.Modules.Inventory.Contracts; +using BunkFy.Modules.Reservations.Application.Handlers; +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Domain.Events; +using BunkFy.Modules.Reservations.Persistence; +using Gma.Framework.Application.Events; +using Gma.Framework.Domain; +using Gma.Framework.Messaging; +using Gma.Framework.Messaging.Infrastructure; +using Gma.Framework.Runtime; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; +using Gma.Framework.Scoping; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Options; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class ReservationsInboxDomainEventDispatchTests +{ + private const string ScopeId = "tenant-a"; + private static readonly DateTimeOffset Now = new(2026, 8, 11, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task Allocation_confirmation_dispatches_and_persists_each_outbox_event_once() + { + await using ReservationsDbContext dbContext = CreateDbContext(); + Reservation reservation = await SeedPendingReservationAsync(dbContext); + TestClock clock = new(); + TestIdGenerator ids = new(); + ReservationsOutboxWriter outbox = CreateOutboxWriter(dbContext, clock); + TestOutboxWriterRegistry outboxWriters = new(outbox); + ProjectingDomainEventDispatcher dispatcher = new( + new ReservationGuestStayChangedOutboxProjector(outboxWriters, ids)); + InventoryAllocationConfirmedHandler handler = CreateHandler( + reservation, + outboxWriters, + dispatcher, + clock, + ids); + ReservationsInboxStore store = new(dbContext, clock, ids, dispatcher); + InventoryAllocationConfirmedIntegrationEvent outcome = CreateOutcome(reservation); + + InboxProcessResult result = await store.ProcessAsync( + CreateMessage(outcome), + cancellationToken => handler.HandleAsync(outcome, cancellationToken), + CancellationToken.None); + + Assert.Equal(InboxProcessStatus.Processed, result.Status); + Assert.Equal(ReservationState.Confirmed, reservation.Status); + Assert.Empty(reservation.DomainEvents); + Assert.Single(dispatcher.DispatchedEvents); + Assert.IsType(dispatcher.DispatchedEvents[0]); + Assert.Equal(1, dispatcher.ProjectedGuestStayChangeCount); + + OutboxMessage[] persistedOutbox = await dbContext.OutboxMessages + .AsNoTracking() + .ToArrayAsync(); + Assert.Equal(2, persistedOutbox.Length); + Assert.Single( + persistedOutbox, + message => message.EventType == typeof(ReservationGuestStayChangedIntegrationEvent).FullName); + Assert.Single( + persistedOutbox, + message => message.EventType == typeof(ReservationConfirmedIntegrationEvent).FullName); + Assert.Equal( + InboxMessageStatus.Processed, + (await dbContext.InboxMessages.AsNoTracking().SingleAsync()).Status); + } + + [Fact] + public async Task Allocation_confirmation_dispatch_failure_retains_events_and_rolls_back_mutation() + { + await using ReservationsDbContext dbContext = CreateDbContext(); + Reservation reservation = await SeedPendingReservationAsync(dbContext); + TestClock clock = new(); + TestIdGenerator ids = new(); + ReservationsOutboxWriter outbox = CreateOutboxWriter(dbContext, clock); + TestOutboxWriterRegistry outboxWriters = new(outbox); + ThrowingDomainEventDispatcher dispatcher = new(); + InventoryAllocationConfirmedHandler handler = CreateHandler( + reservation, + outboxWriters, + dispatcher, + clock, + ids); + ReservationsInboxStore store = new(dbContext, clock, ids, dispatcher); + InventoryAllocationConfirmedIntegrationEvent outcome = CreateOutcome(reservation); + + InboxProcessResult result = await store.ProcessAsync( + CreateMessage(outcome), + cancellationToken => handler.HandleAsync(outcome, cancellationToken), + CancellationToken.None); + + Assert.Equal(InboxProcessStatus.Failed, result.Status); + Assert.Contains("inbox-handler-failed:InvalidOperationException", result.Error); + Assert.Single(dispatcher.DispatchedEvents); + Assert.IsType( + Assert.Single(reservation.DomainEvents)); + Assert.Equal( + ReservationState.PendingAllocation, + (await dbContext.Reservations.AsNoTracking().SingleAsync()).Status); + Assert.Empty(await dbContext.OutboxMessages.AsNoTracking().ToArrayAsync()); + Assert.Equal( + InboxMessageStatus.Failed, + (await dbContext.InboxMessages.AsNoTracking().SingleAsync()).Status); + } + + private static InventoryAllocationConfirmedHandler CreateHandler( + Reservation reservation, + IOutboxWriterRegistry outboxWriters, + IDomainEventDispatcher dispatcher, + ISystemClock clock, + IIdGenerator ids) => new( + ReservationMutationTestSupport.Create( + new TestReservationRepository(reservation), + scopeContext: new TestScopeContext()), + new RecordingInventoryProjection(), + outboxWriters, + new ReservationInboxDomainEventDispatcher(dispatcher), + clock, + ids); + + private static ReservationsDbContext CreateDbContext() + { + DbContextOptions options = + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .ConfigureWarnings(warnings => + warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + return new ReservationsDbContext(options, new TestScopeContext()); + } + + private static async Task SeedPendingReservationAsync( + ReservationsDbContext dbContext) + { + Reservation reservation = Reservation.Create( + Guid.NewGuid(), + ScopeId, + Guid.NewGuid(), + Guid.NewGuid(), + new DateOnly(2026, 8, 12), + new DateOnly(2026, 8, 14), + [Guid.NewGuid()], + "Ada Guest", + "ada@example.test", + phone: null, + guestCount: 1, + ReservationSource.Direct, + sourceSystem: null, + sourceReference: null, + notes: null, + Guid.NewGuid(), + Guid.NewGuid(), + ReservationDetailsChangeOrigin.Staff, + initialDetailsActorId: "user:owner-a", + initialAdapterConnectionId: null, + initialExternalOperationId: null, + Guid.NewGuid(), + Now).Value; + Assert.True(reservation.LinkGuest( + Guid.NewGuid(), + ReservationGuestRole.Primary, + replaceExistingRole: false, + reservation.Version, + "user:owner-a", + Guid.NewGuid(), + Now).IsSuccess); + reservation.ClearDomainEvents(); + dbContext.Reservations.Add(reservation); + await dbContext.SaveChangesAsync(); + return reservation; + } + + private static InventoryAllocationConfirmedIntegrationEvent CreateOutcome( + Reservation reservation) => new( + Guid.NewGuid(), + ScopeId, + Now, + Guid.NewGuid(), + reservation.Id, + reservation.AllocationRequestId, + reservation.PropertyId, + reservation.Arrival, + reservation.Departure, + reservation.RequestedUnits.Select(unit => unit.InventoryUnitId).ToArray(), + allocationVersion: 1); + + private static InboxMessageRecord CreateMessage( + InventoryAllocationConfirmedIntegrationEvent outcome) => new( + outcome.EventId, + ReservationsModuleMetadata.AllocationConfirmedHandlerName, + InventoryIntegrationSubjects.CreateAllocationConfirmed(), + InventoryAllocationConfirmedIntegrationEvent.EventType, + InventoryAllocationConfirmedIntegrationEvent.EventVersion, + scopeId: null, + outcome.OccurredAtUtc); + + private static ReservationsOutboxWriter CreateOutboxWriter( + ReservationsDbContext dbContext, + ISystemClock clock) => new( + dbContext, + clock, + Options.Create(new ApplicationIdentityOptions { Namespace = "bunkfy-test" }), + [new TestScopeResolver()]); + + private sealed class TestReservationRepository(Reservation reservation) + : IReservationRepository + { + public Task AddAsync(Reservation value, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetAsync( + Guid propertyId, + Guid reservationId, + CancellationToken cancellationToken) => + Task.FromResult( + reservation.PropertyId == propertyId && reservation.Id == reservationId + ? reservation + : null); + + public Task GetForDataRightsAsync( + Guid propertyId, + Guid reservationId, + CancellationToken cancellationToken) => + this.GetAsync(propertyId, reservationId, cancellationToken); + + public Task GetAsyncByReservationId( + Guid reservationId, + CancellationToken cancellationToken) => + Task.FromResult(reservation.Id == reservationId ? reservation : null); + + public Task GetByExternalSourceAsync( + string sourceSystem, + string sourceReference, + CancellationToken cancellationToken) => + Task.FromResult(null); + + public Task ExternalSourceExistsAsync( + string sourceSystem, + string sourceReference, + CancellationToken cancellationToken) => + Task.FromResult(false); + + public Task ListAsync( + Guid propertyId, + IReadOnlyCollection? statuses, + string? search, + ReservationListOrder order, + Gma.Framework.Pagination.PageRequest pageRequest, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + + private sealed class RecordingInventoryProjection : IInventoryProjectionRepository + { + public Task ApplyAllocationAsync( + ReservationInventoryAllocationWriteModel allocation, + CancellationToken cancellationToken) => Task.CompletedTask; + + public Task ValidateSelectionAsync( + Guid propertyId, + IReadOnlyCollection inventoryUnitIds, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task ApplyUnitAsync( + ReservationInventoryUnitWriteModel unit, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task ApplyBlockAsync( + ReservationInventoryBlockWriteModel block, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task ReleaseBlockAsync( + string scopeId, + Guid propertyId, + Guid inventoryUnitId, + Guid blockId, + long version, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task ReleaseAllocationAsync( + string scopeId, + Guid allocationId, + Guid reservationId, + long version, + CancellationToken cancellationToken) => throw new NotSupportedException(); + } + + private sealed class TestOutboxWriterRegistry(IOutboxWriter writer) + : IOutboxWriterRegistry + { + public IOutboxWriter GetRequired(string moduleName) + { + Assert.Equal(ReservationsModuleMetadata.Name, moduleName); + return writer; + } + } + + private sealed class ProjectingDomainEventDispatcher( + ReservationGuestStayChangedOutboxProjector projector) + : IDomainEventDispatcher + { + public List DispatchedEvents { get; } = []; + public int ProjectedGuestStayChangeCount { get; private set; } + + public async Task DispatchAsync( + IReadOnlyCollection domainEvents, + CancellationToken cancellationToken) + { + this.DispatchedEvents.AddRange(domainEvents); + foreach (ReservationGuestStayChangedDomainEvent domainEvent in + domainEvents.OfType()) + { + await projector.HandleAsync(domainEvent, cancellationToken); + this.ProjectedGuestStayChangeCount++; + } + } + } + + private sealed class ThrowingDomainEventDispatcher : IDomainEventDispatcher + { + public List DispatchedEvents { get; } = []; + + public Task DispatchAsync( + IReadOnlyCollection domainEvents, + CancellationToken cancellationToken) + { + this.DispatchedEvents.AddRange(domainEvents); + throw new InvalidOperationException("Domain event dispatch failed."); + } + } + + private sealed class TestScopeResolver : IIntegrationEventScopeResolver + { + public string? ResolveScopeId(IIntegrationEvent integrationEvent) => + integrationEvent is IScopedIntegrationEvent scoped + ? scoped.ScopeId + : null; + } + + private sealed class TestScopeContext : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => ReservationsInboxDomainEventDispatchTests.ScopeId; + } + + private sealed class TestClock : ISystemClock + { + public DateTimeOffset UtcNow => Now; + } + + private sealed class TestIdGenerator : IIdGenerator + { + public Guid NewId() => Guid.NewGuid(); + } +} diff --git a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Contracts/StaffPersonalDataCatalogTests.cs b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Contracts/StaffPersonalDataCatalogTests.cs index 75842223..97bcac20 100644 --- a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Contracts/StaffPersonalDataCatalogTests.cs +++ b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Contracts/StaffPersonalDataCatalogTests.cs @@ -1,6 +1,7 @@ namespace BunkFy.Modules.Staff.Tests; using System.Reflection; +using System.Security.Cryptography; using BunkFy.DataGovernance; using BunkFy.Modules.Staff.AdminApi; using BunkFy.Modules.Staff.Api; @@ -92,6 +93,32 @@ public void Catalogue_version_matches_tenant_termination_contract() Catalogue.CatalogVersion); } + [Fact] + public void V19_catalogue_and_owner_manifest_have_immutable_digests() + { + string dataGovernanceDirectory = Path.Combine( + AppContext.BaseDirectory, + "DataGovernance"); + + Assert.Equal(4, StaffTenantTerminationMetadata.CatalogVersion); + Assert.Equal(19, StaffTenantTerminationMetadata.PersonalDataCatalogVersion); + Assert.Equal(3, StaffTenantTerminationMetadata.ExportSchemaVersion); + Assert.Equal(19, Catalogue.CatalogVersion); + Assert.Equal( + "2ea2548e0142b9b9dcb36fdbab663337f511dd57d49f7d86d4ce098fa400b325", + ComputeSha256(Path.Combine( + dataGovernanceDirectory, + "personal-data-catalog.v1.json"))); + Assert.Equal( + "046696ce337d86aebdf4a6b2d2ab2158333559c9138a3227e700cf3ad7da549b", + StaffTenantTerminationMetadata.CatalogSha256); + Assert.Equal( + "30e3e20610d9cdc7bca48c30d2c9c38eeede0511986e88a7fae43fa16cedf95c", + ComputeSha256(Path.Combine( + dataGovernanceDirectory, + "personal-data-inventory.v1.md"))); + } + [Fact] public void Every_catalogue_binding_resolves_to_a_real_member() { @@ -426,6 +453,9 @@ private static PersonalDataCatalogDocument LoadCatalogue() => PersonalDataCatalo "DataGovernance", "personal-data-catalog.v1.json"))); + private static string ComputeSha256(string path) => + Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path))); + private static StaffDbContext CreateDbContext() { DbContextOptions options = new DbContextOptionsBuilder() diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Api/WorkspacesApiEndpointSupport.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Api/WorkspacesApiEndpointSupport.cs index 6ea747a2..3641ca68 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Api/WorkspacesApiEndpointSupport.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Api/WorkspacesApiEndpointSupport.cs @@ -42,6 +42,7 @@ internal static class WorkspacesApiEndpointSupport new(WorkspaceStaffOnboardingApplicationErrors.ApplicationNotFound.Code, StatusCodes.Status404NotFound), new(WorkspaceStaffOnboardingApplicationErrors.ProvisioningFailed.Code, StatusCodes.Status409Conflict), new(WorkspaceStaffOnboardingApplicationErrors.AccessPlanUnavailable.Code, StatusCodes.Status409Conflict), + new(WorkspaceStaffOnboardingApplicationErrors.ProfileMutationAuthorityUnavailable.Code, StatusCodes.Status409Conflict), new(WorkspaceStaffOnboardingApplicationErrors.CorrectionRequestInvalid.Code, StatusCodes.Status400BadRequest), new(WorkspaceStaffOnboardingApplicationErrors.DataRightsApprovalRequired.Code, StatusCodes.Status403Forbidden), new(WorkspaceStaffOnboardingApplicationErrors.CorrectionTargetUnavailable.Code, StatusCodes.Status409Conflict), diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler.cs index 98d8b92b..7c2cff23 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler.cs @@ -12,12 +12,14 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; using Gma.Framework.Runtime.Identity; using Gma.Framework.Runtime.Time; using Gma.Framework.Scoping; +using Gma.Modules.Organizations.Contracts; internal sealed class ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler( IWorkspaceStaffOnboardingCorrectionReceiptRepository receipts, WorkspaceStaffOnboardingMutationCoordinator mutations, WorkspaceStaffOnboardingDataRightsCorrectionAuthorizer authorizer, + IOrganizationEnrollmentClaimInspector claims, IScopeContext scopeContext, ISystemClock clock, IIdGenerator ids) @@ -112,7 +114,42 @@ await mutations.AcquireExistingAsync( .ApplicationNotFound); } + if (command.ExpectedVersion != application.Version) + { + return Result.Failure< + WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( + WorkspaceStaffOnboardingErrors.CorrectionVersionConflict); + } + + if (application.Status != WorkspaceStaffOnboardingState.Submitted) + { + return Result.Failure< + WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( + WorkspaceStaffOnboardingErrors.CorrectionUnavailable); + } + DateTimeOffset nowUtc = ToPersistencePrecision(clock.UtcNow); + Guid organizationId = Guid.TryParse( + application.ScopeId, + out Guid parsedOrganizationId) + ? parsedOrganizationId + : Guid.Empty; + if (await WorkspaceStaffOnboardingProfileMutationAuthority + .IsFencedAsync( + claims, + application.SourceKind, + organizationId, + application.SourceId, + application.SubjectId, + nowUtc, + cancellationToken).ConfigureAwait(false)) + { + return Result.Failure< + WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( + WorkspaceStaffOnboardingApplicationErrors + .CorrectionTargetUnavailable); + } + Result updated = application.ApplyDataRightsCorrection( requested.Value, diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/OrganizationStaffOnboardingExpiryHandlers.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/OrganizationStaffOnboardingExpiryHandlers.cs index 880a71d2..1c3d5f82 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/OrganizationStaffOnboardingExpiryHandlers.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/OrganizationStaffOnboardingExpiryHandlers.cs @@ -58,6 +58,7 @@ private static void EnsureObserved(Result result, string observation) internal sealed class OrganizationEnrollmentClaimExpiredStaffOnboardingHandler( IWorkspaceStaffOnboardingRepository applications, IWorkspaceStaffAccessPlanRepository plans, + IWorkspaceStaffDeferredClaimWithdrawalRepository deferredWithdrawals, WorkspaceStaffOnboardingMutationCoordinator mutations, ISystemClock clock) : IIntegrationEventHandler @@ -75,8 +76,24 @@ await mutations.AcquireSourceAsync( cancellationToken).ConfigureAwait(false); if (application is null) { + IReadOnlyList active = + await applications.ListActiveBySourceAsync( + WorkspaceStaffOnboardingSource.EnrollmentLink, + integrationEvent.EnrollmentLinkId, + cancellationToken).ConfigureAwait(false); + WorkspaceStaffAccessPlan? unboundPlan = await plans.GetAsync( + integrationEvent.EnrollmentLinkId, + cancellationToken).ConfigureAwait(false); + bool hasDeferred = await deferredWithdrawals.AnyBySourceAsync( + integrationEvent.EnrollmentLinkId, + cancellationToken).ConfigureAwait(false); + if (unboundPlan is null && active.Count == 0 && !hasDeferred) + { + return; + } + throw new InvalidOperationException( - "An expired organization enrollment claim had no BunkFy Staff onboarding application."); + "An expired product-owned organization enrollment claim had no BunkFy Staff onboarding application."); } if (!await mutations.AcquireTrackedUnderSourceAsync( @@ -88,12 +105,33 @@ await mutations.AcquireSourceAsync( } if (application.SourceKind != WorkspaceStaffOnboardingSource.EnrollmentLink || - application.SourceId != integrationEvent.EnrollmentLinkId) + application.SourceId != integrationEvent.EnrollmentLinkId || + !string.Equals( + application.ScopeId, + integrationEvent.ScopeId, + StringComparison.Ordinal)) { throw new InvalidOperationException( "An expired organization enrollment claim did not match its BunkFy Staff onboarding source."); } + WorkspaceStaffAccessPlan? applicationPlan = await plans.GetAsync( + integrationEvent.EnrollmentLinkId, + cancellationToken).ConfigureAwait(false); + EnsurePlanMatches( + applicationPlan, + integrationEvent.ScopeId, + integrationEvent.OrganizationId, + integrationEvent.EnrollmentLinkId, + "expired organization enrollment claim"); + if (await deferredWithdrawals.GetAsync( + integrationEvent.ClaimId, + cancellationToken).ConfigureAwait(false) is not null) + { + throw new InvalidOperationException( + "An expired organization enrollment claim conflicted with a durable withdrawal observation."); + } + DateTimeOffset nowUtc = clock.UtcNow; Result expired = application.ObserveClaimExpired( integrationEvent.ClaimId, @@ -104,6 +142,7 @@ await mutations.AcquireSourceAsync( await ExpirePlanWhenUnusedUnderSourceLockAsync( applications, plans, + deferredWithdrawals, application.SourceId, nowUtc, cancellationToken).ConfigureAwait(false); @@ -121,6 +160,7 @@ private static void EnsureObserved(Result result, string observation) internal static async Task ExpirePlanWhenUnusedUnderSourceLockAsync( IWorkspaceStaffOnboardingRepository applications, IWorkspaceStaffAccessPlanRepository plans, + IWorkspaceStaffDeferredClaimWithdrawalRepository deferredWithdrawals, Guid enrollmentLinkId, DateTimeOffset nowUtc, CancellationToken cancellationToken) @@ -142,7 +182,29 @@ internal static async Task ExpirePlanWhenUnusedUnderSourceLockAsync( return; } - EnsureObserved(plan?.Expire(nowUtc) ?? Result.Success(), "enrollment access-plan expiry"); + EnsureObserved(plan.Expire(nowUtc), "enrollment access-plan expiry"); + await deferredWithdrawals.RemoveBySourceAsync( + enrollmentLinkId, + cancellationToken).ConfigureAwait(false); + } + + internal static void EnsurePlanMatches( + WorkspaceStaffAccessPlan? plan, + string scopeId, + Guid organizationId, + Guid enrollmentLinkId, + string observation) + { + if (plan is null || + plan.Id != enrollmentLinkId || + plan.SourceKind != WorkspaceStaffOnboardingSource.EnrollmentLink || + !string.Equals(plan.ScopeId, scopeId, StringComparison.Ordinal) || + !Guid.TryParse(scopeId, out Guid scopedOrganizationId) || + scopedOrganizationId != organizationId) + { + throw new InvalidOperationException( + $"A product-owned {observation} did not match its BunkFy Staff access plan."); + } } } @@ -150,6 +212,7 @@ internal static async Task ExpirePlanWhenUnusedUnderSourceLockAsync( internal sealed class OrganizationEnrollmentLinkExpiredStaffOnboardingHandler( IWorkspaceStaffOnboardingRepository applications, IWorkspaceStaffAccessPlanRepository plans, + IWorkspaceStaffDeferredClaimWithdrawalRepository deferredWithdrawals, WorkspaceStaffOnboardingMutationCoordinator mutations, ISystemClock clock) : IIntegrationEventHandler @@ -166,15 +229,40 @@ await mutations.AcquireSourceAsync( WorkspaceStaffAccessPlan? plan = await plans.GetAsync( integrationEvent.EnrollmentLinkId, cancellationToken).ConfigureAwait(false); + if (plan is null) + { + IReadOnlyList active = + await applications.ListActiveBySourceAsync( + WorkspaceStaffOnboardingSource.EnrollmentLink, + integrationEvent.EnrollmentLinkId, + cancellationToken).ConfigureAwait(false); + bool hasDeferred = await deferredWithdrawals.AnyBySourceAsync( + integrationEvent.EnrollmentLinkId, + cancellationToken).ConfigureAwait(false); + if (active.Count > 0 || hasDeferred) + { + throw new InvalidOperationException( + "An expired organization enrollment link retained BunkFy Staff onboarding state without its access plan."); + } + + return; + } + + OrganizationEnrollmentClaimExpiredStaffOnboardingHandler.EnsurePlanMatches( + plan, + integrationEvent.ScopeId, + integrationEvent.OrganizationId, + integrationEvent.EnrollmentLinkId, + "expired organization enrollment link"); EnsureObserved( - plan?.ObserveSourceExpired(integrationEvent.ExpiresAtUtc, nowUtc) ?? - Result.Success(), + plan.ObserveSourceExpired(integrationEvent.ExpiresAtUtc, nowUtc), "enrollment access-plan source expiry"); await OrganizationEnrollmentClaimExpiredStaffOnboardingHandler .ExpirePlanWhenUnusedUnderSourceLockAsync( applications, plans, + deferredWithdrawals, integrationEvent.EnrollmentLinkId, nowUtc, cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/OrganizationStaffOnboardingIntegrationHandlers.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/OrganizationStaffOnboardingIntegrationHandlers.cs index 4558a728..f116a264 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/OrganizationStaffOnboardingIntegrationHandlers.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/OrganizationStaffOnboardingIntegrationHandlers.cs @@ -95,6 +95,7 @@ private static async Task ProcessWhenPresentAsync( internal sealed class OrganizationEnrollmentClaimStaffOnboardingHandler( IWorkspaceStaffOnboardingRepository applications, IWorkspaceStaffAccessPlanRepository plans, + IWorkspaceStaffDeferredClaimWithdrawalRepository deferredWithdrawals, WorkspaceStaffOnboardingMutationCoordinator mutations, WorkspaceStaffOnboardingProcessor processor, ISystemClock clock, @@ -105,21 +106,41 @@ public async Task HandleAsync( OrganizationEnrollmentClaimChangedIntegrationEvent integrationEvent, CancellationToken cancellationToken) { - WorkspaceStaffOnboardingSourceLockMode sourceLockMode = - integrationEvent.Change == OrganizationEnrollmentClaimChange.Requested - ? WorkspaceStaffOnboardingSourceLockMode.Read - : WorkspaceStaffOnboardingSourceLockMode.Write; WorkspaceStaffOnboardingMutationLease lease = await mutations.AcquireApplicantAsync( WorkspaceStaffOnboardingSource.EnrollmentLink, integrationEvent.EnrollmentLinkId, integrationEvent.SubjectId, - sourceLockMode, + WorkspaceStaffOnboardingSourceLockMode.Write, requireOperational: false, cancellationToken).ConfigureAwait(false); WorkspaceStaffOnboarding? application = lease.Application; if (application is null) { + WorkspaceStaffAccessPlan? plan = await plans.GetAsync( + integrationEvent.EnrollmentLinkId, + cancellationToken).ConfigureAwait(false); + WorkspaceStaffDeferredClaimWithdrawal? deferred = + await deferredWithdrawals.GetAsync( + integrationEvent.ClaimId, + cancellationToken).ConfigureAwait(false); + if (plan is not null) + { + OrganizationEnrollmentClaimExpiredStaffOnboardingHandler + .EnsurePlanMatches( + plan, + integrationEvent.ScopeId, + integrationEvent.OrganizationId, + integrationEvent.EnrollmentLinkId, + "changed organization enrollment claim"); + } + + if (plan is not null || deferred is not null) + { + throw new InvalidOperationException( + "A product-owned organization enrollment claim had no BunkFy Staff onboarding application."); + } + logger.LogWarning("An organization enrollment claim had no BunkFy Staff onboarding application."); return; } @@ -132,6 +153,32 @@ await mutations.AcquireApplicantAsync( integrationEvent.ClaimVersion, nowUtc); EnsureObserved(requested, "claim request"); + + if (await this.ObserveDeferredWithdrawalAsync( + application, + integrationEvent, + nowUtc, + cancellationToken).ConfigureAwait(false)) + { + await this.FinalizePlanAsync( + application.SourceId, + nowUtc, + cancellationToken).ConfigureAwait(false); + } + + return; + } + + if (await this.ObserveDeferredWithdrawalAsync( + application, + integrationEvent, + nowUtc, + cancellationToken).ConfigureAwait(false)) + { + await this.FinalizePlanAsync( + application.SourceId, + nowUtc, + cancellationToken).ConfigureAwait(false); return; } @@ -146,6 +193,7 @@ await OrganizationEnrollmentClaimExpiredStaffOnboardingHandler .ExpirePlanWhenUnusedUnderSourceLockAsync( applications, plans, + deferredWithdrawals, application.SourceId, nowUtc, cancellationToken) @@ -173,6 +221,7 @@ await OrganizationEnrollmentClaimExpiredStaffOnboardingHandler .ExpirePlanWhenUnusedUnderSourceLockAsync( applications, plans, + deferredWithdrawals, application.SourceId, nowUtc, cancellationToken) @@ -188,12 +237,63 @@ private static void EnsureObserved(Result result, string observation) $"Staff onboarding could not observe {observation}: '{result.Error.Code}'."); } } + + private async Task ObserveDeferredWithdrawalAsync( + WorkspaceStaffOnboarding application, + OrganizationEnrollmentClaimChangedIntegrationEvent integrationEvent, + DateTimeOffset nowUtc, + CancellationToken cancellationToken) + { + WorkspaceStaffDeferredClaimWithdrawal? deferred = + await deferredWithdrawals.GetAsync( + integrationEvent.ClaimId, + cancellationToken).ConfigureAwait(false); + if (deferred is null) + { + return false; + } + + if (deferred.Id != integrationEvent.ClaimId || + deferred.OrganizationId != integrationEvent.OrganizationId || + deferred.EnrollmentLinkId != integrationEvent.EnrollmentLinkId || + !string.Equals( + deferred.ScopeId, + integrationEvent.ScopeId, + StringComparison.Ordinal) || + deferred.ClaimVersion <= integrationEvent.ClaimVersion) + { + throw new InvalidOperationException( + "A deferred organization enrollment claim withdrawal did not follow its changed claim coordinate."); + } + + Result withdrawn = application.ObserveClaimWithdrawn( + deferred.Id, + deferred.ClaimVersion, + nowUtc); + EnsureObserved(withdrawn, "deferred claim withdrawal"); + deferredWithdrawals.Remove(deferred); + return true; + } + + private Task FinalizePlanAsync( + Guid enrollmentLinkId, + DateTimeOffset nowUtc, + CancellationToken cancellationToken) => + OrganizationEnrollmentClaimExpiredStaffOnboardingHandler + .ExpirePlanWhenUnusedUnderSourceLockAsync( + applications, + plans, + deferredWithdrawals, + enrollmentLinkId, + nowUtc, + cancellationToken); } [IntegrationEventHandler(WorkspacesModuleMetadata.EnrollmentLinkChangedHandlerName)] internal sealed class OrganizationEnrollmentLinkStaffOnboardingHandler( IWorkspaceStaffOnboardingRepository applications, IWorkspaceStaffAccessPlanRepository plans, + IWorkspaceStaffDeferredClaimWithdrawalRepository deferredWithdrawals, WorkspaceStaffOnboardingMutationCoordinator mutations, ISystemClock clock) : IIntegrationEventHandler @@ -225,6 +325,29 @@ await mutations.AcquireSourceAsync( WorkspaceStaffAccessPlan? plan = await plans.GetAsync( integrationEvent.EnrollmentLinkId, cancellationToken).ConfigureAwait(false); - plan?.Supersede(clock.UtcNow); + bool hasDeferred = await deferredWithdrawals.AnyBySourceAsync( + integrationEvent.EnrollmentLinkId, + cancellationToken).ConfigureAwait(false); + if (plan is null) + { + if (active.Count > 0 || hasDeferred) + { + throw new InvalidOperationException( + "A terminal organization enrollment link retained BunkFy Staff onboarding state without its access plan."); + } + + return; + } + + OrganizationEnrollmentClaimExpiredStaffOnboardingHandler.EnsurePlanMatches( + plan, + integrationEvent.ScopeId, + integrationEvent.OrganizationId, + integrationEvent.EnrollmentLinkId, + "terminal organization enrollment link"); + plan.Supersede(clock.UtcNow); + await deferredWithdrawals.RemoveBySourceAsync( + integrationEvent.EnrollmentLinkId, + cancellationToken).ConfigureAwait(false); } } diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/OrganizationStaffOnboardingWithdrawalHandler.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/OrganizationStaffOnboardingWithdrawalHandler.cs index aed51d4d..3de4bc16 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/OrganizationStaffOnboardingWithdrawalHandler.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/OrganizationStaffOnboardingWithdrawalHandler.cs @@ -12,6 +12,7 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; internal sealed class OrganizationEnrollmentClaimWithdrawnStaffOnboardingHandler( IWorkspaceStaffOnboardingRepository applications, IWorkspaceStaffAccessPlanRepository plans, + IWorkspaceStaffDeferredClaimWithdrawalRepository deferredWithdrawals, WorkspaceStaffOnboardingMutationCoordinator mutations, ISystemClock clock) : IIntegrationEventHandler @@ -27,10 +28,74 @@ await mutations.AcquireSourceAsync( WorkspaceStaffOnboarding? application = await applications.GetByClaimAsync( integrationEvent.ClaimId, cancellationToken).ConfigureAwait(false); + WorkspaceStaffDeferredClaimWithdrawal? deferred = + await deferredWithdrawals.GetAsync( + integrationEvent.ClaimId, + cancellationToken).ConfigureAwait(false); if (application is null) { - throw new InvalidOperationException( - "A withdrawn organization enrollment claim had no BunkFy Staff onboarding application."); + IReadOnlyList active = + await applications.ListActiveBySourceAsync( + WorkspaceStaffOnboardingSource.EnrollmentLink, + integrationEvent.EnrollmentLinkId, + cancellationToken).ConfigureAwait(false); + WorkspaceStaffAccessPlan? plan = await plans.GetAsync( + integrationEvent.EnrollmentLinkId, + cancellationToken).ConfigureAwait(false); + if (plan is null) + { + if (active.Count > 0 || deferred is not null) + { + throw new InvalidOperationException( + "A product-owned organization enrollment claim withdrawal lost its BunkFy Staff access plan."); + } + + return; + } + + EnsurePlanMatches(plan, integrationEvent); + if (plan.Status != WorkspaceStaffAccessPlanState.Active) + { + if (active.Count > 0) + { + throw new InvalidOperationException( + "A terminal BunkFy Staff access plan retained an active onboarding application."); + } + + if (deferred is not null) + { + EnsureDeferredMatches(deferred, integrationEvent); + deferredWithdrawals.Remove(deferred); + } + + return; + } + + if (deferred is not null) + { + EnsureDeferredMatches(deferred, integrationEvent); + return; + } + + Result created = + WorkspaceStaffDeferredClaimWithdrawal.Create( + integrationEvent.ScopeId, + integrationEvent.OrganizationId, + integrationEvent.EnrollmentLinkId, + integrationEvent.ClaimId, + integrationEvent.ClaimVersion, + integrationEvent.EventId, + integrationEvent.OccurredAtUtc); + if (created.IsFailure) + { + throw new InvalidOperationException( + $"Staff onboarding could not defer claim withdrawal: '{created.Error.Code}'."); + } + + await deferredWithdrawals.AddAsync( + created.Value, + cancellationToken).ConfigureAwait(false); + return; } if (!await mutations.AcquireTrackedUnderSourceAsync( @@ -42,12 +107,31 @@ await mutations.AcquireSourceAsync( } if (application.SourceKind != WorkspaceStaffOnboardingSource.EnrollmentLink || - application.SourceId != integrationEvent.EnrollmentLinkId) + application.SourceId != integrationEvent.EnrollmentLinkId || + !string.Equals( + application.ScopeId, + integrationEvent.ScopeId, + StringComparison.Ordinal)) { throw new InvalidOperationException( "A withdrawn organization enrollment claim did not match its BunkFy Staff onboarding source."); } + WorkspaceStaffAccessPlan? applicationPlan = await plans.GetAsync( + integrationEvent.EnrollmentLinkId, + cancellationToken).ConfigureAwait(false); + if (applicationPlan is null) + { + throw new InvalidOperationException( + "A withdrawn organization enrollment claim matched a BunkFy Staff onboarding application without its access plan."); + } + + EnsurePlanMatches(applicationPlan, integrationEvent); + if (deferred is not null) + { + EnsureDeferredMatches(deferred, integrationEvent); + } + DateTimeOffset nowUtc = clock.UtcNow; Result withdrawn = application.ObserveClaimWithdrawn( integrationEvent.ClaimId, @@ -59,12 +143,54 @@ await mutations.AcquireSourceAsync( $"Staff onboarding could not observe claim withdrawal: '{withdrawn.Error.Code}'."); } + if (deferred is not null) + { + deferredWithdrawals.Remove(deferred); + } + await OrganizationEnrollmentClaimExpiredStaffOnboardingHandler .ExpirePlanWhenUnusedUnderSourceLockAsync( applications, plans, + deferredWithdrawals, application.SourceId, nowUtc, cancellationToken).ConfigureAwait(false); } + + private static void EnsurePlanMatches( + WorkspaceStaffAccessPlan plan, + OrganizationEnrollmentClaimWithdrawnIntegrationEvent integrationEvent) + { + if (plan.Id != integrationEvent.EnrollmentLinkId || + plan.SourceKind != WorkspaceStaffOnboardingSource.EnrollmentLink || + !string.Equals( + plan.ScopeId, + integrationEvent.ScopeId, + StringComparison.Ordinal) || + !Guid.TryParse(integrationEvent.ScopeId, out Guid organizationId) || + organizationId != integrationEvent.OrganizationId) + { + throw new InvalidOperationException( + "A withdrawn organization enrollment claim did not match its BunkFy Staff access plan."); + } + } + + private static void EnsureDeferredMatches( + WorkspaceStaffDeferredClaimWithdrawal deferred, + OrganizationEnrollmentClaimWithdrawnIntegrationEvent integrationEvent) + { + if (!deferred.Matches( + integrationEvent.ScopeId, + integrationEvent.OrganizationId, + integrationEvent.EnrollmentLinkId, + integrationEvent.ClaimId, + integrationEvent.ClaimVersion, + integrationEvent.EventId, + integrationEvent.OccurredAtUtc)) + { + throw new InvalidOperationException( + "A duplicate organization enrollment claim withdrawal conflicted with its durable BunkFy observation."); + } + } } diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ReconcileWorkspaceStaffOnboardingRetentionCandidateCommandHandler.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ReconcileWorkspaceStaffOnboardingRetentionCandidateCommandHandler.cs index 1cbc576f..d891e620 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ReconcileWorkspaceStaffOnboardingRetentionCandidateCommandHandler.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ReconcileWorkspaceStaffOnboardingRetentionCandidateCommandHandler.cs @@ -12,6 +12,7 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; internal sealed class ReconcileWorkspaceStaffOnboardingRetentionCandidateCommandHandler( IWorkspaceStaffOnboardingRepository applications, IWorkspaceStaffAccessPlanRepository plans, + IWorkspaceStaffDeferredClaimWithdrawalRepository deferredWithdrawals, WorkspaceStaffOnboardingMutationCoordinator mutations, IOrganizationEnrollmentClaimInspector claims, WorkspaceStaffOnboardingProcessor processor, @@ -35,9 +36,7 @@ await mutations.AcquireExistingAsync( if (application is null || application.Version != command.ExpectedVersion || application.SourceKind != WorkspaceStaffOnboardingSource.EnrollmentLink || - application.Status != WorkspaceStaffOnboardingState.Submitted || - application.ClaimId.HasValue || - application.ClaimVersion.HasValue) + !IsEligibleState(application)) { return Unchanged(); } @@ -78,6 +77,14 @@ await mutations.AcquireExistingAsync( cancellationToken).ConfigureAwait(false); if (claim is null) { + if (await deferredWithdrawals.AnyBySourceAsync( + application.SourceId, + cancellationToken).ConfigureAwait(false)) + { + return Result.Failure( + WorkspaceStaffOnboardingApplicationErrors.RetentionClaimInconsistent); + } + if (sourceExpiredAtUtc.Value <= nowUtc - settings.AuthorityWindow) { return Success( @@ -110,6 +117,17 @@ await this.FinalizePlanAsync(application.SourceId, nowUtc, cancellationToken) WorkspaceStaffOnboardingApplicationErrors.RetentionPlanInconsistent); } + WorkspaceStaffDeferredClaimWithdrawal? deferred = + await deferredWithdrawals.GetAsync( + claim.ClaimId, + cancellationToken).ConfigureAwait(false); + if (deferred is not null && + claim.Status != OrganizationEnrollmentClaimStatus.Withdrawn) + { + return Result.Failure( + WorkspaceStaffOnboardingApplicationErrors.RetentionClaimInconsistent); + } + return claim.Status switch { OrganizationEnrollmentClaimStatus.Pending => @@ -122,7 +140,11 @@ await this.ObserveExpiredAsync( application, claim, nowUtc, cancellationToken).ConfigureAwait(false), OrganizationEnrollmentClaimStatus.Withdrawn => await this.ObserveWithdrawnAsync( - application, claim, nowUtc, cancellationToken).ConfigureAwait(false), + application, + claim, + deferred, + nowUtc, + cancellationToken).ConfigureAwait(false), OrganizationEnrollmentClaimStatus.Accepted => await this.ObserveAcceptedAsync( application, claim, nowUtc, cancellationToken).ConfigureAwait(false), @@ -136,6 +158,7 @@ private static Result ObservePe OrganizationEnrollmentClaimDto claim, DateTimeOffset nowUtc) { + long versionBefore = application.Version; Result observed = application.ObserveClaimRequested( claim.ClaimId, claim.Version, @@ -144,7 +167,7 @@ private static Result ObservePe ? Failure(observed) : Success( WorkspaceStaffOnboardingRetentionOutcome.ClaimPending, - affected: true); + affected: application.Version != versionBefore); } private async Task> ObserveRejectedAsync( @@ -221,9 +244,26 @@ await this.FinalizePlanAsync(application.SourceId, nowUtc, cancellationToken) private async Task> ObserveWithdrawnAsync( WorkspaceStaffOnboarding application, OrganizationEnrollmentClaimDto claim, + WorkspaceStaffDeferredClaimWithdrawal? deferred, DateTimeOffset nowUtc, CancellationToken cancellationToken) { + if (deferred is not null) + { + if (deferred.Id != claim.ClaimId || + deferred.ClaimVersion != claim.Version || + deferred.OrganizationId != claim.OrganizationId || + deferred.EnrollmentLinkId != claim.EnrollmentLinkId || + !string.Equals( + deferred.ScopeId, + application.ScopeId, + StringComparison.Ordinal)) + { + return Result.Failure( + WorkspaceStaffOnboardingApplicationErrors.RetentionClaimInconsistent); + } + } + Result observed = application.ObserveClaimWithdrawn( claim.ClaimId, claim.Version, @@ -233,6 +273,11 @@ private async Task> Obse return Failure(observed); } + if (deferred is not null) + { + deferredWithdrawals.Remove(deferred); + } + await this.FinalizePlanAsync(application.SourceId, nowUtc, cancellationToken) .ConfigureAwait(false); return Success( @@ -248,6 +293,7 @@ private Task FinalizePlanAsync( .ExpirePlanWhenUnusedUnderSourceLockAsync( applications, plans, + deferredWithdrawals, enrollmentLinkId, nowUtc, cancellationToken); @@ -260,11 +306,24 @@ private static bool IsConsistent( claim.Version > 0 && claim.OrganizationId == organizationId && claim.EnrollmentLinkId == application.SourceId && + (!application.ClaimId.HasValue || + (application.ClaimId.Value == claim.ClaimId && + application.ClaimVersion.HasValue && + claim.Version >= application.ClaimVersion.Value)) && string.Equals( claim.SubjectId, application.SubjectId, StringComparison.Ordinal); + private static bool IsEligibleState( + WorkspaceStaffOnboarding application) => + (application.Status == WorkspaceStaffOnboardingState.Submitted && + !application.ClaimId.HasValue && + !application.ClaimVersion.HasValue) || + (application.Status == WorkspaceStaffOnboardingState.PendingApproval && + application.ClaimId.HasValue && + application.ClaimVersion.HasValue); + private static Result Unchanged() => Success(WorkspaceStaffOnboardingRetentionOutcome.Unchanged, affected: false); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/RetryWorkspaceStaffOnboardingCommandHandler.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/RetryWorkspaceStaffOnboardingCommandHandler.cs index da84dc6d..cac3381b 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/RetryWorkspaceStaffOnboardingCommandHandler.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/RetryWorkspaceStaffOnboardingCommandHandler.cs @@ -12,6 +12,7 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; internal sealed class RetryWorkspaceStaffOnboardingCommandHandler( IWorkspaceStaffOnboardingRepository applications, IWorkspaceStaffAccessPlanRepository plans, + IWorkspaceStaffDeferredClaimWithdrawalRepository deferredWithdrawals, WorkspaceStaffOnboardingProcessor processor, ISystemClock clock) : ICommandHandler @@ -39,6 +40,7 @@ await OrganizationEnrollmentClaimExpiredStaffOnboardingHandler .ExpirePlanWhenUnusedUnderSourceLockAsync( applications, plans, + deferredWithdrawals, application.SourceId, clock.UtcNow, cancellationToken) diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/SubmitWorkspaceStaffOnboardingCommandHandler.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/SubmitWorkspaceStaffOnboardingCommandHandler.cs index 3e535b4b..885a253b 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/SubmitWorkspaceStaffOnboardingCommandHandler.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/SubmitWorkspaceStaffOnboardingCommandHandler.cs @@ -12,6 +12,7 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; using Gma.Framework.Runtime.Time; using Gma.Framework.Scoping; using Gma.Modules.Auth.Contracts; +using Gma.Modules.Organizations.Contracts; using Microsoft.Extensions.Options; internal sealed class SubmitWorkspaceStaffOnboardingCommandHandler( @@ -22,6 +23,7 @@ internal sealed class SubmitWorkspaceStaffOnboardingCommandHandler( IWorkspaceStaffAccessPlanRepository plans, WorkspaceStaffJoinTokenAuthorityResolver authorityResolver, IAuthMemberAdmissionReader admissions, + IOrganizationEnrollmentClaimInspector claims, IOptions options, WorkspaceOperationalAdmissionEvaluator operationalAdmission, IScopeContext scopeContext, @@ -120,6 +122,21 @@ await mutations.AcquireApplicantAsync( } DateTimeOffset nowUtc = clock.UtcNow; + if (await WorkspaceStaffOnboardingProfileMutationAuthority + .IsFencedAsync( + claims, + sourceKind, + authority.Value.OrganizationId, + authority.Value.SourceId, + memberId.ToString("D"), + nowUtc, + cancellationToken).ConfigureAwait(false)) + { + return Result.Failure( + WorkspaceStaffOnboardingApplicationErrors + .ProfileMutationAuthorityUnavailable); + } + if (application is null) { Result created = WorkspaceStaffOnboarding.Create( diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingProcessingRestrictionRecoveryHandler.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingProcessingRestrictionRecoveryHandler.cs index 942bc7e4..3e49b501 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingProcessingRestrictionRecoveryHandler.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingProcessingRestrictionRecoveryHandler.cs @@ -5,6 +5,7 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; using BunkFy.Modules.Workspaces.Domain; using Gma.Framework.Messaging; using Gma.Framework.Results; +using Gma.Framework.Runtime.Time; using Microsoft.Extensions.Logging; [IntegrationEventHandler( @@ -12,7 +13,10 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; internal sealed class WorkspaceStaffOnboardingProcessingRestrictionRecoveryHandler( IWorkspaceStaffOnboardingRepository applications, + IWorkspaceStaffAccessPlanRepository plans, + IWorkspaceStaffDeferredClaimWithdrawalRepository deferredWithdrawals, WorkspaceStaffOnboardingProcessor processor, + ISystemClock clock, ILogger< WorkspaceStaffOnboardingProcessingRestrictionRecoveryHandler> logger) @@ -42,11 +46,24 @@ public async Task HandleAsync( return; } - Result recovered = await processor.ProcessAsync( + Result recovered = await processor.ProcessForSourceFinalizationAsync( application, cancellationToken).ConfigureAwait(false); if (recovered.IsSuccess) { + if (application.SourceKind == + WorkspaceStaffOnboardingSource.EnrollmentLink) + { + await OrganizationEnrollmentClaimExpiredStaffOnboardingHandler + .ExpirePlanWhenUnusedUnderSourceLockAsync( + applications, + plans, + deferredWithdrawals, + application.SourceId, + clock.UtcNow, + cancellationToken).ConfigureAwait(false); + } + return; } diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingProfileMutationAuthority.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingProfileMutationAuthority.cs new file mode 100644 index 00000000..b65ccae1 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingProfileMutationAuthority.cs @@ -0,0 +1,69 @@ +namespace BunkFy.Modules.Workspaces.Application.Handlers; + +using BunkFy.Modules.Workspaces.Domain; +using Gma.Modules.Organizations.Contracts; + +internal static class WorkspaceStaffOnboardingProfileMutationAuthority +{ + public static async Task IsFencedAsync( + IOrganizationEnrollmentClaimInspector claims, + WorkspaceStaffOnboardingSource sourceKind, + Guid organizationId, + Guid sourceId, + string subjectId, + DateTimeOffset nowUtc, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(claims); + if (sourceKind == WorkspaceStaffOnboardingSource.Invitation) + { + return false; + } + + if (sourceKind != WorkspaceStaffOnboardingSource.EnrollmentLink || + organizationId == Guid.Empty || + sourceId == Guid.Empty || + string.IsNullOrWhiteSpace(subjectId)) + { + return true; + } + + OrganizationEnrollmentClaimDto? claim = await claims.FindAsync( + organizationId, + sourceId, + subjectId, + cancellationToken) + .ConfigureAwait(false); + if (claim is null) + { + return false; + } + + DateTimeOffset persistenceNowUtc = ToPersistencePrecision(nowUtc); + DateTimeOffset? decisionExpiresAtUtc = claim.DecisionExpiresAtUtc + .HasValue + ? ToPersistencePrecision(claim.DecisionExpiresAtUtc.Value) + : null; + + return claim.OrganizationId != organizationId || + claim.EnrollmentLinkId != sourceId || + !string.Equals( + claim.SubjectId, + subjectId, + StringComparison.Ordinal) || + claim.Status != OrganizationEnrollmentClaimStatus.Pending || + !decisionExpiresAtUtc.HasValue || + decisionExpiresAtUtc.Value <= persistenceNowUtc; + } + + private static DateTimeOffset ToPersistencePrecision( + DateTimeOffset value) + { + const long ticksPerMicrosecond = + TimeSpan.TicksPerMillisecond / 1000; + DateTimeOffset utc = value.ToUniversalTime(); + return new( + utc.Ticks - (utc.Ticks % ticksPerMicrosecond), + TimeSpan.Zero); + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffDeferredClaimWithdrawalRepository.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffDeferredClaimWithdrawalRepository.cs new file mode 100644 index 00000000..67b2cfc3 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffDeferredClaimWithdrawalRepository.cs @@ -0,0 +1,24 @@ +namespace BunkFy.Modules.Workspaces.Application.Ports; + +using BunkFy.Modules.Workspaces.Domain; + +public interface IWorkspaceStaffDeferredClaimWithdrawalRepository +{ + Task GetAsync( + Guid claimId, + CancellationToken cancellationToken); + + Task AnyBySourceAsync( + Guid enrollmentLinkId, + CancellationToken cancellationToken); + + Task AddAsync( + WorkspaceStaffDeferredClaimWithdrawal withdrawal, + CancellationToken cancellationToken); + + void Remove(WorkspaceStaffDeferredClaimWithdrawal withdrawal); + + Task RemoveBySourceAsync( + Guid enrollmentLinkId, + CancellationToken cancellationToken); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffOnboardingApplicationErrors.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffOnboardingApplicationErrors.cs index 40163cfd..fb774e58 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffOnboardingApplicationErrors.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffOnboardingApplicationErrors.cs @@ -14,6 +14,9 @@ public static class WorkspaceStaffOnboardingApplicationErrors public static readonly Error AccessPlanUnavailable = new( "Workspaces.StaffAccessPlanUnavailable", "The workspace Staff access plan is unavailable."); + public static readonly Error ProfileMutationAuthorityUnavailable = new( + "Workspaces.StaffOnboardingProfileMutationAuthorityUnavailable", + "The Staff onboarding profile is no longer editable under the authoritative join state."); public static readonly Error RetentionCoordinateInvalid = new( "Workspaces.StaffOnboardingRetentionCoordinateInvalid", "The Staff onboarding retention coordinate is invalid."); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspacesTenantTerminationMetadata.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspacesTenantTerminationMetadata.cs index 7c285a4d..c3b1fb6f 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspacesTenantTerminationMetadata.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspacesTenantTerminationMetadata.cs @@ -8,8 +8,8 @@ public static class WorkspacesTenantTerminationMetadata public const string OwnerKey = "workspaces"; public const string PropertiesDestroyDependencyOwnerKey = "properties"; public const string TaskRuntimeDestroyDependencyOwnerKey = "task-runtime"; - public const int CatalogVersion = 11; - public const int PersonalDataCatalogVersion = 11; + public const int CatalogVersion = 12; + public const int PersonalDataCatalogVersion = 12; public const string ExportSchemaId = "workspaces.tenant-termination-export"; public const int ExportSchemaVersion = 1; diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffDeferredClaimWithdrawal.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffDeferredClaimWithdrawal.cs new file mode 100644 index 00000000..a296ff01 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffDeferredClaimWithdrawal.cs @@ -0,0 +1,91 @@ +namespace BunkFy.Modules.Workspaces.Domain; + +using Gma.Framework.Domain.Models; +using Gma.Framework.Naming; +using Gma.Framework.Results; + +public sealed class WorkspaceStaffDeferredClaimWithdrawal + : ScopedEntity +{ + private WorkspaceStaffDeferredClaimWithdrawal() { } + + private WorkspaceStaffDeferredClaimWithdrawal( + Guid claimId, + string scopeId) + : base(claimId, scopeId) + { + } + + public Guid OrganizationId { get; private set; } + public Guid EnrollmentLinkId { get; private set; } + public long ClaimVersion { get; private set; } + public Guid EventId { get; private set; } + public DateTimeOffset OccurredAtUtc { get; private set; } + + public static Result Create( + string scopeId, + Guid organizationId, + Guid enrollmentLinkId, + Guid claimId, + long claimVersion, + Guid eventId, + DateTimeOffset occurredAtUtc) + { + if (!TenantIds.TryNormalize(scopeId, out string? normalizedScopeId) || + !Guid.TryParse(normalizedScopeId, out Guid scopedOrganizationId) || + scopedOrganizationId != organizationId || + organizationId == Guid.Empty || + enrollmentLinkId == Guid.Empty || + claimId == Guid.Empty || + claimVersion <= 0 || + eventId == Guid.Empty || + occurredAtUtc == default) + { + return Result.Failure( + WorkspaceStaffOnboardingErrors.Invalid); + } + + return Result.Success( + new WorkspaceStaffDeferredClaimWithdrawal( + claimId, + organizationId.ToString("D")) + { + OrganizationId = organizationId, + EnrollmentLinkId = enrollmentLinkId, + ClaimVersion = claimVersion, + EventId = eventId, + OccurredAtUtc = NormalizeTimestamp(occurredAtUtc) + }); + } + + public bool Matches( + string scopeId, + Guid organizationId, + Guid enrollmentLinkId, + Guid claimId, + long claimVersion, + Guid eventId, + DateTimeOffset occurredAtUtc) => + TenantIds.TryNormalize(scopeId, out string? normalizedScopeId) && + Guid.TryParse(normalizedScopeId, out Guid scopedOrganizationId) && + scopedOrganizationId == organizationId && + string.Equals( + this.ScopeId, + organizationId.ToString("D"), + StringComparison.Ordinal) && + this.OrganizationId == organizationId && + this.EnrollmentLinkId == enrollmentLinkId && + this.Id == claimId && + this.ClaimVersion == claimVersion && + this.EventId == eventId && + this.OccurredAtUtc == NormalizeTimestamp(occurredAtUtc); + + private static DateTimeOffset NormalizeTimestamp( + DateTimeOffset timestamp) + { + DateTimeOffset utc = timestamp.ToUniversalTime(); + return new DateTimeOffset( + utc.Ticks - (utc.Ticks % TimeSpan.TicksPerMicrosecond), + TimeSpan.Zero); + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations/Migrations/20260811044039_AddWorkspaceStaffDeferredClaimWithdrawals.Designer.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations/Migrations/20260811044039_AddWorkspaceStaffDeferredClaimWithdrawals.Designer.cs new file mode 100644 index 00000000..f1ffae8f --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations/Migrations/20260811044039_AddWorkspaceStaffDeferredClaimWithdrawals.Designer.cs @@ -0,0 +1,1575 @@ +// +using System; +using BunkFy.Modules.Workspaces.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations.Migrations +{ + [DbContext(typeof(WorkspacesDbContext))] + [Migration("20260811044039_AddWorkspaceStaffDeferredClaimWithdrawals")] + partial class AddWorkspaceStaffDeferredClaimWithdrawals + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("workspaces") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.DataRights.WorkspaceStaffCorrelationAnonymisationReceipt", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessPlanRecordsScrubbed") + .HasColumnType("integer"); + + b.Property("AccessProcessRecordsScrubbed") + .HasColumnType("integer"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AnchorProcessId") + .HasColumnType("uuid"); + + b.Property("ApprovalEvidenceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ApprovalRevision") + .HasColumnType("bigint"); + + b.Property("CanonicalSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("Disposition") + .HasColumnType("integer"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("OnboardingRecordsScrubbed") + .HasColumnType("integer"); + + b.Property("OperationRevision") + .HasColumnType("bigint"); + + b.Property("Reason") + .HasColumnType("integer"); + + b.Property("ResultingAnchorVersion") + .HasColumnType("bigint"); + + b.Property("ResultingStateSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedAnchorVersion") + .HasColumnType("bigint"); + + b.Property("SelectedStaffVersion") + .HasColumnType("bigint"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.Property("StateBindingSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "AnchorProcessId") + .IsUnique(); + + b.HasIndex("ScopeId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("ScopeId", "StaffMemberId", "SelectedStaffVersion") + .IsUnique(); + + b.ToTable("staff_correlation_anonymisation_receipts", "workspaces", t => + { + t.HasCheckConstraint("CK_staff_correlation_anonymisation_receipt_contract", "\"ContractVersion\" = 1"); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_receipt_counts", "\"OnboardingRecordsScrubbed\" >= 0 AND \"AccessProcessRecordsScrubbed\" > 0 AND \"AccessPlanRecordsScrubbed\" >= 0"); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_receipt_hashes", "char_length(\"ApprovalEvidenceSha256\") = 64 AND char_length(\"StateBindingSha256\") = 64 AND char_length(\"ResultingStateSha256\") = 64 AND char_length(\"CanonicalSha256\") = 64"); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_receipt_outcome", "\"Disposition\" = 1 AND \"Reason\" = 1"); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_receipt_revisions", "\"ApprovalRevision\" > 0 AND \"OperationRevision\" > \"ApprovalRevision\""); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_receipt_versions", "\"SelectedStaffVersion\" > 0 AND \"SelectedAnchorVersion\" > 0 AND \"ResultingAnchorVersion\" = \"SelectedAnchorVersion\" + 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.DataRights.WorkspaceStaffCorrelationAnonymisationRestoreReceipt", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessPlanRecordsScrubbed") + .HasColumnType("integer"); + + b.Property("AccessProcessRecordsScrubbed") + .HasColumnType("integer"); + + b.Property("AnchorProcessId") + .HasColumnType("uuid"); + + b.Property("CanonicalSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("LedgerEntryId") + .HasColumnType("uuid"); + + b.Property("LedgerEntrySha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("OnboardingRecordsScrubbed") + .HasColumnType("integer"); + + b.Property("OriginallyCompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OwnerReceiptContractVersion") + .HasColumnType("integer"); + + b.Property("OwnerReceiptId") + .HasColumnType("uuid"); + + b.Property("OwnerReceiptSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ReplayedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ResultingAnchorVersion") + .HasColumnType("bigint"); + + b.Property("ResultingStateSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.Property("TenantSequence") + .HasColumnType("bigint"); + + b.Property("TombstoneRevision") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "AnchorProcessId", "LedgerEntryId") + .IsUnique(); + + b.ToTable("staff_correlation_anonymisation_restore_receipts", "workspaces", t => + { + t.HasCheckConstraint("CK_staff_correlation_anonymisation_restore_contract", "\"ContractVersion\" = 1"); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_restore_counts", "\"OnboardingRecordsScrubbed\" >= 0 AND \"AccessProcessRecordsScrubbed\" > 0 AND \"AccessPlanRecordsScrubbed\" >= 0"); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_restore_hashes", "char_length(\"LedgerEntrySha256\") = 64 AND char_length(\"OwnerReceiptSha256\") = 64 AND char_length(\"ResultingStateSha256\") = 64 AND char_length(\"CanonicalSha256\") = 64"); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_restore_identity", "\"LedgerEntryId\" = \"Id\""); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_restore_receipt", "\"TenantSequence\" > 0 AND \"OwnerReceiptContractVersion\" > 0"); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_restore_revision", "\"TombstoneRevision\" > 0"); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_restore_times", "\"ReplayedAtUtc\" >= \"OriginallyCompletedAtUtc\""); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_restore_version", "\"ResultingAnchorVersion\" > 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.DataRights.WorkspaceStaffCorrelationAnonymisationTombstone", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("LastReplayedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LedgerEntryId") + .HasColumnType("uuid"); + + b.Property("OwnerReceiptContractVersion") + .HasColumnType("integer"); + + b.Property("OwnerReceiptId") + .HasColumnType("uuid"); + + b.Property("OwnerReceiptSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ResultingAnchorVersion") + .HasColumnType("bigint"); + + b.Property("ResultingStateSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedAnchorVersion") + .HasColumnType("bigint"); + + b.Property("SelectedStaffVersion") + .HasColumnType("bigint"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId", "LedgerEntryId") + .IsUnique(); + + b.HasIndex("ScopeId", "OwnerReceiptId") + .IsUnique(); + + b.HasIndex("ScopeId", "StaffMemberId", "SelectedStaffVersion") + .IsUnique(); + + b.ToTable("staff_correlation_anonymisation_tombstones", "workspaces", t => + { + t.HasCheckConstraint("CK_staff_correlation_anonymisation_tombstone_contract", "\"ContractVersion\" = 1"); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_tombstone_hashes", "char_length(\"OwnerReceiptSha256\") = 64 AND char_length(\"ResultingStateSha256\") = 64"); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_tombstone_receipt", "\"OwnerReceiptContractVersion\" > 0"); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_tombstone_replay", "(\"LedgerEntryId\" IS NULL AND \"LastReplayedAtUtc\" IS NULL) OR (\"LedgerEntryId\" IS NOT NULL AND \"LastReplayedAtUtc\" IS NOT NULL AND \"LastReplayedAtUtc\" >= \"CompletedAtUtc\")"); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_tombstone_revision", "\"Revision\" > 0"); + + t.HasCheckConstraint("CK_staff_correlation_anonymisation_tombstone_versions", "\"SelectedStaffVersion\" > 0 AND \"SelectedAnchorVersion\" > 0 AND \"ResultingAnchorVersion\" = \"SelectedAnchorVersion\" + 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.DataRights.WorkspaceStaffOnboardingCorrectionReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicantEventId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ApprovalRevision") + .HasColumnType("bigint"); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("ChangedFieldsMask") + .HasColumnType("integer"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletionEventId") + .HasColumnType("uuid"); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("CurrentRecordVersion") + .HasColumnType("bigint"); + + b.Property("ExecutionId") + .HasColumnType("uuid"); + + b.Property("RequestSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedRecordVersion") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "ApplicantEventId") + .IsUnique(); + + b.HasIndex("ScopeId", "CompletionEventId") + .IsUnique(); + + b.HasIndex("ScopeId", "ExecutionId") + .IsUnique(); + + b.HasIndex("ScopeId", "CaseId", "ApprovalRevision"); + + b.HasIndex("ScopeId", "ApplicationId", "CompletedAtUtc", "Id"); + + b.ToTable("staff_onboarding_correction_receipts", "workspaces", t => + { + t.HasCheckConstraint("CK_workspaces_staff_onboarding_correction_receipts_approval", "\"ApprovalRevision\" >= 1"); + + t.HasCheckConstraint("CK_workspaces_staff_onboarding_correction_receipts_contract", "\"ContractVersion\" = 1"); + + t.HasCheckConstraint("CK_workspaces_staff_onboarding_correction_receipts_digest", "char_length(\"RequestSha256\") = 64"); + + t.HasCheckConstraint("CK_workspaces_staff_onboarding_correction_receipts_fields", "\"ChangedFieldsMask\" BETWEEN 1 AND 127"); + + t.HasCheckConstraint("CK_workspaces_staff_onboarding_correction_receipts_versions", "\"SelectedRecordVersion\" >= 1 AND \"CurrentRecordVersion\" = \"SelectedRecordVersion\" + 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.DataRights.WorkspaceStaffOnboardingProcessingRestriction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AppliedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AppliedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ApplyApprovalRevision") + .HasColumnType("bigint"); + + b.Property("ApplyCaseId") + .HasColumnType("uuid"); + + b.Property("ApplySelectedOnboardingVersion") + .HasColumnType("bigint"); + + b.Property("ReleaseApprovalRevision") + .HasColumnType("bigint"); + + b.Property("ReleaseCaseId") + .HasColumnType("uuid"); + + b.Property("ReleaseSelectedOnboardingVersion") + .HasColumnType("bigint"); + + b.Property("ReleasedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleasedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "ApplicationId", "ApplyCaseId", "ApplyApprovalRevision") + .IsUnique(); + + b.HasIndex("ScopeId", "ApplicationId", "ReleaseCaseId", "ReleaseApprovalRevision") + .IsUnique() + .HasDatabaseName("IX_staff_onboarding_processing_restrictions_ScopeId_Applicati~1"); + + b.HasIndex("ScopeId", "ApplicationId", "Status", "AppliedAtUtc") + .HasDatabaseName("IX_staff_onboarding_processing_restrictions_ScopeId_Applicati~2"); + + b.ToTable("staff_onboarding_processing_restrictions", "workspaces", t => + { + t.HasCheckConstraint("CK_ws_onboarding_restrictions_apply", "\"ApplyApprovalRevision\" >= 1 AND \"ApplySelectedOnboardingVersion\" >= 1"); + + t.HasCheckConstraint("CK_ws_onboarding_restrictions_lifecycle", "(\"Status\" = 1 AND \"ReleaseCaseId\" IS NULL AND \"ReleaseApprovalRevision\" IS NULL AND \"ReleaseSelectedOnboardingVersion\" IS NULL AND \"ReleasedBy\" IS NULL AND \"ReleasedAtUtc\" IS NULL AND \"Version\" = 1) OR (\"Status\" = 2 AND \"ReleaseCaseId\" IS NOT NULL AND \"ReleaseApprovalRevision\" >= 1 AND \"ReleaseSelectedOnboardingVersion\" >= 1 AND \"ReleasedBy\" IS NOT NULL AND \"ReleasedAtUtc\" IS NOT NULL AND \"ReleasedAtUtc\" >= \"AppliedAtUtc\" AND \"Version\" >= 2)"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.DataRights.WorkspaceStaffOnboardingProcessingRestrictionProjection", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ActiveRestrictionCount") + .HasColumnType("integer"); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("IsRestricted") + .HasColumnType("boolean"); + + b.Property("LastTransitionAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectionOrdinal") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectionOrdinal")); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("ScopeId", "ApplicationId"); + + b.HasIndex("ProjectionOrdinal") + .IsUnique(); + + b.HasIndex("ScopeId", "IsRestricted", "ApplicationId"); + + b.ToTable("staff_onboarding_processing_restriction_state", "workspaces", t => + { + t.HasCheckConstraint("CK_ws_onboarding_restriction_contract", "\"ContractVersion\" >= 1"); + + t.HasCheckConstraint("CK_ws_onboarding_restriction_revision", "\"Revision\" >= 0"); + + t.HasCheckConstraint("CK_ws_onboarding_restriction_state", "(\"ActiveRestrictionCount\" = 0 AND NOT \"IsRestricted\") OR (\"ActiveRestrictionCount\" > 0 AND \"IsRestricted\")"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.DataRights.WorkspaceStaffOnboardingProcessingRestrictionReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ApprovalRevision") + .HasColumnType("bigint"); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveRestricted") + .HasColumnType("boolean"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("RestrictionId") + .HasColumnType("uuid"); + + b.Property("ResultingProjectionRevision") + .HasColumnType("bigint"); + + b.Property("ResultingRestrictionVersion") + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedOnboardingVersion") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("ScopeId", "ApplicationId", "CompletedAtUtc"); + + b.HasIndex("ScopeId", "CaseId", "ApprovalRevision"); + + b.ToTable("staff_onboarding_processing_restriction_receipts", "workspaces", t => + { + t.HasCheckConstraint("CK_ws_onboarding_restriction_receipt_versions", "\"ApprovalRevision\" >= 1 AND \"SelectedOnboardingVersion\" >= 1 AND \"ResultingProjectionRevision\" >= 1 AND ((\"Action\" = 1 AND \"ResultingRestrictionVersion\" = 1 AND \"EffectiveRestricted\") OR (\"Action\" = 2 AND \"ResultingRestrictionVersion\" >= 2))"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.Termination.WorkspaceTerminationFence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalRevision") + .HasColumnType("bigint"); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastChangedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastChangedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PolicyEvidenceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ProcessId") + .HasColumnType("uuid"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("TerminationEpoch") + .HasColumnType("uuid"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId") + .IsUnique() + .HasDatabaseName("UX_workspace_termination_fences_active_scope") + .HasFilter("\"State\" IN (1, 2, 3)"); + + b.HasIndex("ScopeId", "ProcessId") + .IsUnique(); + + b.HasIndex("ScopeId", "TerminationEpoch") + .IsUnique(); + + b.ToTable("workspace_termination_fences", "workspaces", t => + { + t.HasCheckConstraint("CK_workspace_termination_fence_policy_digest", "char_length(\"PolicyEvidenceSha256\") = 64"); + + t.HasCheckConstraint("CK_workspace_termination_fence_revisions", "\"ApprovalRevision\" >= 1 AND \"Version\" >= 1"); + + t.HasCheckConstraint("CK_workspace_termination_fence_state", "\"State\" BETWEEN 1 AND 4"); + + t.HasCheckConstraint("CK_workspace_termination_fence_timestamps", "\"CreatedAtUtc\" <= \"LastChangedAtUtc\""); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.Termination.WorkspaceTerminationFenceReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ApprovalRevision") + .HasColumnType("bigint"); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FenceId") + .HasColumnType("uuid"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("OperationRevision") + .HasColumnType("bigint"); + + b.Property("PolicyEvidenceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ProcessId") + .HasColumnType("uuid"); + + b.Property("ResultingFenceVersion") + .HasColumnType("bigint"); + + b.Property("ResultingState") + .HasColumnType("integer"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedFenceVersion") + .HasColumnType("bigint"); + + b.Property("TerminationEpoch") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId", "FenceId"); + + b.HasIndex("ScopeId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("ScopeId", "ProcessId", "CompletedAtUtc"); + + b.HasIndex("ScopeId", "ProcessId", "Action", "OperationRevision") + .IsUnique(); + + b.ToTable("workspace_termination_fence_receipts", "workspaces", t => + { + t.HasCheckConstraint("CK_workspace_termination_receipt_policy_digest", "char_length(\"PolicyEvidenceSha256\") = 64"); + + t.HasCheckConstraint("CK_workspace_termination_receipt_revisions", "\"ApprovalRevision\" >= 1 AND \"OperationRevision\" >= 1 AND ((\"Action\" = 1 AND \"SelectedFenceVersion\" = 0 AND \"ResultingFenceVersion\" = 1 AND \"ResultingState\" = 1) OR (\"Action\" = 2 AND \"SelectedFenceVersion\" >= 1 AND \"ResultingFenceVersion\" = \"SelectedFenceVersion\" + 1 AND \"ResultingState\" = 2) OR (\"Action\" = 3 AND \"SelectedFenceVersion\" >= 2 AND \"ResultingFenceVersion\" = \"SelectedFenceVersion\" + 1 AND \"ResultingState\" = 3) OR (\"Action\" = 4 AND \"SelectedFenceVersion\" >= 1 AND \"ResultingFenceVersion\" = \"SelectedFenceVersion\" + 1 AND \"ResultingState\" = 4))"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBySubjectId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastChangedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceExpiredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceKind") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId", "CreatedBySubjectId", "Id"); + + b.HasIndex("ScopeId", "SourceKind", "SourceExpiredAtUtc", "Id"); + + b.HasIndex("ScopeId", "Status", "CreatedAtUtc", "Id"); + + b.ToTable("staff_access_plans", "workspaces", t => + { + t.HasCheckConstraint("CK_staff_access_plans_expiry_authority", "\"Status\" <> 4 OR \"SourceExpiredAtUtc\" IS NOT NULL"); + + t.HasCheckConstraint("CK_staff_access_plans_source", "\"SourceKind\" IN (1, 2)"); + + t.HasCheckConstraint("CK_staff_access_plans_status", "\"Status\" IN (1, 2, 3, 4)"); + + t.HasCheckConstraint("CK_staff_access_plans_version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessPlanProperty", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PlanId") + .HasColumnType("uuid"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.HasKey("ScopeId", "PlanId", "PropertyId"); + + b.HasIndex("ScopeId", "PropertyId", "PlanId"); + + b.ToTable("staff_access_plan_properties", "workspaces"); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessProcess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveOn") + .HasColumnType("date"); + + b.Property("FailureCode") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastChangedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("SubjectId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TargetStaffVersion") + .HasColumnType("bigint"); + + b.Property("TargetState") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "RequestedBy", "Id"); + + b.HasIndex("ScopeId", "StaffMemberId", "TargetStaffVersion") + .IsUnique(); + + b.HasIndex("ScopeId", "StaffMemberId", "State", "CreatedAtUtc"); + + b.HasIndex("ScopeId", "SubjectId", "State", "Id"); + + b.ToTable("staff_access_processes", "workspaces", t => + { + t.HasCheckConstraint("CK_staff_access_process_staff_version", "\"TargetStaffVersion\" >= 2"); + + t.HasCheckConstraint("CK_staff_access_process_state", "\"State\" BETWEEN 1 AND 4"); + + t.HasCheckConstraint("CK_staff_access_process_target", "\"TargetState\" BETWEEN 1 AND 3"); + + t.HasCheckConstraint("CK_staff_access_process_version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("ClaimId"); + + b.Property("ClaimVersion") + .HasColumnType("bigint"); + + b.Property("EnrollmentLinkId") + .HasColumnType("uuid"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId", "EnrollmentLinkId", "ClaimVersion"); + + b.HasIndex("ScopeId", "Id"); + + b.ToTable("staff_deferred_claim_withdrawals", "workspaces", t => + { + t.HasCheckConstraint("CK_staff_deferred_claim_withdrawal_coordinates", "\"ClaimId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"OrganizationId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"EnrollmentLinkId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"EventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ScopeId\" = \"OrganizationId\"::text"); + + t.HasCheckConstraint("CK_staff_deferred_claim_withdrawal_version", "\"ClaimVersion\" > 0"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClaimId") + .HasColumnType("uuid"); + + b.Property("ClaimVersion") + .HasColumnType("bigint"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmployeeNumber") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FailureCode") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("JobTitle") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastChangedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LegalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("SourceKind") + .HasColumnType("integer"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubjectId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("VerifiedAccountEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("WorkEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("WorkPhone") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "ClaimId") + .IsUnique(); + + b.HasIndex("ScopeId", "StaffMemberId", "Id") + .HasFilter("\"StaffMemberId\" IS NOT NULL"); + + b.HasIndex("ScopeId", "SourceKind", "SourceId", "SubjectId") + .IsUnique(); + + b.HasIndex("ScopeId", "Status", "CreatedAtUtc", "Id"); + + b.HasIndex("ScopeId", "SubjectId", "Status", "Id"); + + b.ToTable("staff_onboarding_applications", "workspaces", t => + { + t.HasCheckConstraint("CK_staff_onboarding_claim", "(\"ClaimId\" IS NULL AND \"ClaimVersion\" IS NULL) OR (\"ClaimId\" IS NOT NULL AND \"ClaimVersion\" > 0)"); + + t.HasCheckConstraint("CK_staff_onboarding_pending_profile", "\"Status\" IN (5, 7, 8, 9, 10) OR (\"VerifiedAccountEmail\" IS NOT NULL AND \"DisplayName\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_staff_onboarding_source", "\"SourceKind\" IN (1, 2)"); + + t.HasCheckConstraint("CK_staff_onboarding_staff", "\"Status\" NOT IN (4, 5) OR \"StaffMemberId\" IS NOT NULL"); + + t.HasCheckConstraint("CK_staff_onboarding_status", "\"Status\" BETWEEN 1 AND 10"); + + t.HasCheckConstraint("CK_staff_onboarding_terminal_redaction", "\"Status\" NOT IN (5, 7, 8, 9, 10) OR (\"VerifiedAccountEmail\" IS NULL AND \"DisplayName\" IS NULL AND \"LegalName\" IS NULL AND \"WorkEmail\" IS NULL AND \"WorkPhone\" IS NULL AND \"EmployeeNumber\" IS NULL AND \"JobTitle\" IS NULL AND \"Department\" IS NULL)"); + + t.HasCheckConstraint("CK_staff_onboarding_version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffRetentionCorrelationReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessPlanRecordsScrubbed") + .HasColumnType("integer"); + + b.Property("AccessProcessRecordsScrubbed") + .HasColumnType("integer"); + + b.Property("CanonicalSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("ExecutionId") + .HasColumnType("uuid"); + + b.Property("OnboardingRecordsScrubbed") + .HasColumnType("integer"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedStaffVersion") + .HasColumnType("bigint"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "ExecutionId", "Id"); + + b.HasIndex("ScopeId", "StaffMemberId", "SelectedStaffVersion") + .IsUnique(); + + b.ToTable("staff_retention_correlation_receipts", "workspaces", t => + { + t.HasCheckConstraint("CK_staff_retention_correlation_receipt_contract", "\"ContractVersion\" = 1"); + + t.HasCheckConstraint("CK_staff_retention_correlation_receipt_counts", "\"OnboardingRecordsScrubbed\" >= 0 AND \"AccessProcessRecordsScrubbed\" >= 0 AND \"AccessPlanRecordsScrubbed\" >= 0"); + + t.HasCheckConstraint("CK_staff_retention_correlation_receipt_hash", "char_length(\"CanonicalSha256\") = 64"); + + t.HasCheckConstraint("CK_staff_retention_correlation_receipt_version", "\"SelectedStaffVersion\" > 0"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Persistence.TenantTermination.WorkspaceTenantDestroyOperation", b => + { + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("BatchSize") + .HasColumnType("integer"); + + b.Property("CompletedBatchCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyVersion") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("FenceId") + .HasColumnType("uuid"); + + b.Property("ProofVersion") + .HasColumnType("integer"); + + b.Property("RemovalProofSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("RemovedRecordCount") + .HasColumnType("bigint"); + + b.Property("RequestSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ResultingFenceVersion") + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedFenceVersion") + .HasColumnType("bigint"); + + b.Property("Stage") + .HasColumnType("integer"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("OperationId"); + + b.HasIndex("ScopeId") + .IsUnique(); + + b.HasIndex("ScopeId", "FenceId"); + + b.ToTable("tenant_destroy_operations", "workspaces", t => + { + t.HasCheckConstraint("CK_workspaces_tenant_destroy_operation_batch", "\"BatchSize\" BETWEEN 1 AND 500"); + + t.HasCheckConstraint("CK_workspaces_tenant_destroy_operation_progress", "\"Stage\" BETWEEN 1 AND 20 AND \"RemovedRecordCount\" >= 0 AND \"CompletedBatchCount\" >= 0 AND \"ProofVersion\" = 1 AND \"ConcurrencyVersion\" >= 1"); + + t.HasCheckConstraint("CK_workspaces_tenant_destroy_operation_revisions", "\"SelectedFenceVersion\" >= 1 AND \"ResultingFenceVersion\" = \"SelectedFenceVersion\" + 2"); + + t.HasCheckConstraint("CK_workspaces_tenant_destroy_operation_times", "\"UpdatedAtUtc\" >= \"StartedAtUtc\""); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Persistence.TenantTermination.WorkspaceTenantDestroyReceipt", b => + { + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("BatchSize") + .HasColumnType("integer"); + + b.Property("CloseFenceReceiptId") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletedBatchCount") + .HasColumnType("integer"); + + b.Property("FenceId") + .HasColumnType("uuid"); + + b.Property("RemovalProofSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("RemovalProofVersion") + .HasColumnType("integer"); + + b.Property("RemovedRecordCount") + .HasColumnType("bigint"); + + b.Property("RequestSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ResultingFenceVersion") + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedFenceVersion") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("OperationId"); + + b.HasIndex("ScopeId") + .IsUnique(); + + b.HasIndex("ScopeId", "CloseFenceReceiptId") + .IsUnique(); + + b.HasIndex("ScopeId", "FenceId"); + + b.ToTable("tenant_destroy_receipts", "workspaces", t => + { + t.HasCheckConstraint("CK_workspaces_tenant_destroy_receipt_progress", "((\"RemovedRecordCount\" = 0 AND \"CompletedBatchCount\" = 0) OR (\"RemovedRecordCount\" > 0 AND \"CompletedBatchCount\" > 0)) AND \"BatchSize\" BETWEEN 1 AND 500 AND \"RemovalProofVersion\" = 1"); + + t.HasCheckConstraint("CK_workspaces_tenant_destroy_receipt_revisions", "\"SelectedFenceVersion\" >= 1 AND \"ResultingFenceVersion\" = \"SelectedFenceVersion\" + 2"); + + t.HasCheckConstraint("CK_workspaces_tenant_destroy_receipt_times", "\"CompletedAtUtc\" >= \"StartedAtUtc\""); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Persistence.WorkspaceProjectionRebuildCheckpoint", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProjectionName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Cursor") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("FailedCount") + .HasColumnType("bigint"); + + b.Property("ProcessedCount") + .HasColumnType("bigint"); + + b.Property("ProjectionVersion") + .HasColumnType("integer"); + + b.Property("SkippedCount") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WrittenCount") + .HasColumnType("bigint"); + + b.HasKey("ScopeId", "ProjectionName", "RunId"); + + b.ToTable("projection_rebuild_checkpoints", "workspaces"); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Persistence.WorkspacePropertyProjection", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "Status", "Id"); + + b.ToTable("property_projection", "workspaces", t => + { + t.HasCheckConstraint("CK_workspaces_property_projection_version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("Gma.Framework.Messaging.Infrastructure.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Handler") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("FailedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LockedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProcessingStartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("ScopeId"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id", "Handler"); + + b.HasIndex("Handler", "Status"); + + b.HasIndex("Status", "ProcessedAtUtc"); + + b.ToTable("inbox_messages", "workspaces"); + }); + + modelBuilder.Entity("Gma.Framework.Messaging.Infrastructure.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("LockedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("ScopeId"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedAtUtc", "NextAttemptAtUtc", "LockedUntilUtc", "CreatedAtUtc"); + + b.ToTable("outbox_messages", "workspaces"); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.DataRights.WorkspaceStaffCorrelationAnonymisationRestoreReceipt", b => + { + b.HasOne("BunkFy.Modules.Workspaces.Domain.DataRights.WorkspaceStaffCorrelationAnonymisationTombstone", null) + .WithMany() + .HasForeignKey("ScopeId", "AnchorProcessId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.Termination.WorkspaceTerminationFenceReceipt", b => + { + b.HasOne("BunkFy.Modules.Workspaces.Domain.Termination.WorkspaceTerminationFence", null) + .WithMany() + .HasForeignKey("ScopeId", "FenceId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessPlanProperty", b => + { + b.HasOne("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessPlan", null) + .WithMany("Properties") + .HasForeignKey("ScopeId", "PlanId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessProcess", b => + { + b.OwnsMany("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessProfileSnapshot", "ProfileSnapshots", b1 => + { + b1.Property("ProcessId") + .HasColumnType("uuid"); + + b1.Property("ProfileId") + .HasColumnType("uuid"); + + b1.Property("AssignmentScope") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b1.HasKey("ProcessId", "ProfileId", "AssignmentScope"); + + b1.ToTable("staff_access_profile_snapshots", "workspaces"); + + b1.WithOwner() + .HasForeignKey("ProcessId"); + }); + + b.Navigation("ProfileSnapshots"); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Persistence.TenantTermination.WorkspaceTenantDestroyOperation", b => + { + b.HasOne("BunkFy.Modules.Workspaces.Domain.Termination.WorkspaceTerminationFence", null) + .WithMany() + .HasForeignKey("ScopeId", "FenceId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Persistence.TenantTermination.WorkspaceTenantDestroyReceipt", b => + { + b.HasOne("BunkFy.Modules.Workspaces.Domain.Termination.WorkspaceTerminationFenceReceipt", null) + .WithMany() + .HasForeignKey("ScopeId", "CloseFenceReceiptId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BunkFy.Modules.Workspaces.Domain.Termination.WorkspaceTerminationFence", null) + .WithMany() + .HasForeignKey("ScopeId", "FenceId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessPlan", b => + { + b.Navigation("Properties"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations/Migrations/20260811044039_AddWorkspaceStaffDeferredClaimWithdrawals.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations/Migrations/20260811044039_AddWorkspaceStaffDeferredClaimWithdrawals.cs new file mode 100644 index 00000000..fb76dfc1 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations/Migrations/20260811044039_AddWorkspaceStaffDeferredClaimWithdrawals.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations.Migrations +{ + /// + public partial class AddWorkspaceStaffDeferredClaimWithdrawals : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "staff_deferred_claim_withdrawals", + schema: "workspaces", + columns: table => new + { + ClaimId = table.Column(type: "uuid", nullable: false), + OrganizationId = table.Column(type: "uuid", nullable: false), + EnrollmentLinkId = table.Column(type: "uuid", nullable: false), + ClaimVersion = table.Column(type: "bigint", nullable: false), + EventId = table.Column(type: "uuid", nullable: false), + OccurredAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ScopeId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_staff_deferred_claim_withdrawals", x => x.ClaimId); + table.CheckConstraint("CK_staff_deferred_claim_withdrawal_coordinates", "\"ClaimId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"OrganizationId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"EnrollmentLinkId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"EventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ScopeId\" = \"OrganizationId\"::text"); + table.CheckConstraint("CK_staff_deferred_claim_withdrawal_version", "\"ClaimVersion\" > 0"); + }); + + migrationBuilder.CreateIndex( + name: "IX_staff_deferred_claim_withdrawals_ScopeId_EnrollmentLinkId_C~", + schema: "workspaces", + table: "staff_deferred_claim_withdrawals", + columns: new[] { "ScopeId", "EnrollmentLinkId", "ClaimVersion" }); + + migrationBuilder.CreateIndex( + name: "IX_staff_deferred_claim_withdrawals_ScopeId_ClaimId", + schema: "workspaces", + table: "staff_deferred_claim_withdrawals", + columns: new[] { "ScopeId", "ClaimId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DO $$ + BEGIN + LOCK TABLE workspaces.staff_deferred_claim_withdrawals + IN ACCESS EXCLUSIVE MODE; + IF EXISTS ( + SELECT 1 + FROM workspaces.staff_deferred_claim_withdrawals + ) THEN + RAISE EXCEPTION 'Cannot remove durable Staff claim withdrawals while deferred observations exist.'; + END IF; + END $$; + """); + + migrationBuilder.DropTable( + name: "staff_deferred_claim_withdrawals", + schema: "workspaces"); + } + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations/Migrations/WorkspacesDbContextModelSnapshot.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations/Migrations/WorkspacesDbContextModelSnapshot.cs index c4da4d70..dc2465fe 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations/Migrations/WorkspacesDbContextModelSnapshot.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations/Migrations/WorkspacesDbContextModelSnapshot.cs @@ -917,6 +917,47 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("ClaimId"); + + b.Property("ClaimVersion") + .HasColumnType("bigint"); + + b.Property("EnrollmentLinkId") + .HasColumnType("uuid"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId", "EnrollmentLinkId", "ClaimVersion"); + + b.HasIndex("ScopeId", "Id"); + + b.ToTable("staff_deferred_claim_withdrawals", "workspaces", t => + { + t.HasCheckConstraint("CK_staff_deferred_claim_withdrawal_coordinates", "\"ClaimId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"OrganizationId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"EnrollmentLinkId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"EventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ScopeId\" = \"OrganizationId\"::text"); + + t.HasCheckConstraint("CK_staff_deferred_claim_withdrawal_version", "\"ClaimVersion\" > 0"); + }); + }); + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding", b => { b.Property("Id") diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffDeferredClaimWithdrawalConfiguration.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffDeferredClaimWithdrawalConfiguration.cs new file mode 100644 index 00000000..b2c1d291 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffDeferredClaimWithdrawalConfiguration.cs @@ -0,0 +1,46 @@ +namespace BunkFy.Modules.Workspaces.Persistence.Configurations; + +using BunkFy.Modules.Workspaces.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +internal sealed class WorkspaceStaffDeferredClaimWithdrawalConfiguration + : IEntityTypeConfiguration +{ + public void Configure( + EntityTypeBuilder builder) + { + builder.ToTable( + "staff_deferred_claim_withdrawals", + table => + { + table.HasCheckConstraint( + "CK_staff_deferred_claim_withdrawal_version", + "\"ClaimVersion\" > 0"); + table.HasCheckConstraint( + "CK_staff_deferred_claim_withdrawal_coordinates", + "\"ClaimId\" <> '00000000-0000-0000-0000-000000000000'::uuid " + + "AND \"OrganizationId\" <> '00000000-0000-0000-0000-000000000000'::uuid " + + "AND \"EnrollmentLinkId\" <> '00000000-0000-0000-0000-000000000000'::uuid " + + "AND \"EventId\" <> '00000000-0000-0000-0000-000000000000'::uuid " + + "AND \"ScopeId\" = \"OrganizationId\"::text"); + }); + builder.HasKey(withdrawal => withdrawal.Id); + builder.Property(withdrawal => withdrawal.Id) + .HasColumnName("ClaimId"); + builder.Property(withdrawal => withdrawal.ScopeId) + .HasMaxLength(128) + .IsRequired(); + builder.HasIndex(withdrawal => new + { + withdrawal.ScopeId, + withdrawal.EnrollmentLinkId, + withdrawal.ClaimVersion + }); + builder.HasIndex(withdrawal => new + { + withdrawal.ScopeId, + withdrawal.Id + }); + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/DependencyInjection.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/DependencyInjection.cs index f1429d8d..2da587e9 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/DependencyInjection.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/DependencyInjection.cs @@ -33,6 +33,9 @@ public static IHostApplicationBuilder AddWorkspacesPersistence( builder.Services.TryAddScoped< IWorkspaceStaffOnboardingRepository, WorkspaceStaffOnboardingRepository>(); + builder.Services.TryAddScoped< + IWorkspaceStaffDeferredClaimWithdrawalRepository, + WorkspaceStaffDeferredClaimWithdrawalRepository>(); builder.Services.TryAddScoped< IWorkspaceStaffOnboardingRetentionRepository, WorkspaceStaffOnboardingRetentionRepository>(); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffDeferredClaimWithdrawalRepository.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffDeferredClaimWithdrawalRepository.cs new file mode 100644 index 00000000..30d8360b --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffDeferredClaimWithdrawalRepository.cs @@ -0,0 +1,64 @@ +namespace BunkFy.Modules.Workspaces.Persistence.Repositories; + +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using Microsoft.EntityFrameworkCore; + +internal sealed class WorkspaceStaffDeferredClaimWithdrawalRepository( + WorkspacesDbContext dbContext) + : IWorkspaceStaffDeferredClaimWithdrawalRepository +{ + public Task GetAsync( + Guid claimId, + CancellationToken cancellationToken) => + dbContext.StaffDeferredClaimWithdrawals.SingleOrDefaultAsync( + withdrawal => withdrawal.Id == claimId, + cancellationToken); + + public Task AnyBySourceAsync( + Guid enrollmentLinkId, + CancellationToken cancellationToken) => + dbContext.StaffDeferredClaimWithdrawals.AnyAsync( + withdrawal => withdrawal.EnrollmentLinkId == enrollmentLinkId, + cancellationToken); + + public async Task AddAsync( + WorkspaceStaffDeferredClaimWithdrawal withdrawal, + CancellationToken cancellationToken) => + await dbContext.StaffDeferredClaimWithdrawals + .AddAsync(withdrawal, cancellationToken) + .ConfigureAwait(false); + + public void Remove(WorkspaceStaffDeferredClaimWithdrawal withdrawal) => + dbContext.StaffDeferredClaimWithdrawals.Remove(withdrawal); + + public async Task RemoveBySourceAsync( + Guid enrollmentLinkId, + CancellationToken cancellationToken) + { + WorkspaceStaffDeferredClaimWithdrawal[] tracked = dbContext + .ChangeTracker + .Entries() + .Where(entry => + entry.State != EntityState.Detached && + entry.Entity.EnrollmentLinkId == enrollmentLinkId) + .Select(entry => entry.Entity) + .ToArray(); + Guid[] trackedClaimIds = tracked + .Select(withdrawal => withdrawal.Id) + .ToArray(); + IQueryable remaining = + dbContext.StaffDeferredClaimWithdrawals.Where(withdrawal => + withdrawal.EnrollmentLinkId == enrollmentLinkId); + if (trackedClaimIds.Length > 0) + { + remaining = remaining.Where(withdrawal => + !trackedClaimIds.Contains(withdrawal.Id)); + } + + int removed = await remaining.ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + dbContext.StaffDeferredClaimWithdrawals.RemoveRange(tracked); + return removed + tracked.Length; + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingRetentionRepository.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingRetentionRepository.cs index bfb42299..1b8854c0 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingRetentionRepository.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingRetentionRepository.cs @@ -27,9 +27,12 @@ join application in dbContext.StaffOnboardingApplications.AsNoTracking() plan.SourceExpiredAtUtc <= sourceExpiredBeforeUtc && application.SourceKind == WorkspaceStaffOnboardingSource.EnrollmentLink && - application.Status == WorkspaceStaffOnboardingState.Submitted && - application.ClaimId == null && - application.ClaimVersion == null + ((application.Status == WorkspaceStaffOnboardingState.Submitted && + application.ClaimId == null && + application.ClaimVersion == null) || + (application.Status == WorkspaceStaffOnboardingState.PendingApproval && + application.ClaimId != null && + application.ClaimVersion != null)) orderby plan.SourceExpiredAtUtc, application.LastChangedAtUtc, application.Id select new WorkspaceStaffOnboardingRetentionCandidate( application.Id, diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportContributor.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportContributor.cs index 846e3728..0c6e9741 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportContributor.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportContributor.cs @@ -17,6 +17,8 @@ internal sealed class WorkspacesDataRightsExportContributor( "staff-access-plan-property"; public const string StaffOnboardingCorrectionReceiptRecordType = "staff-onboarding-correction-receipt"; + public const string StaffDeferredClaimWithdrawalRecordType = + "staff-deferred-claim-withdrawal"; public const string StaffOnboardingProcessingRestrictionRecordType = "staff-onboarding-processing-restriction"; public const string diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportModels.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportModels.cs index 526e8e6c..284719f4 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportModels.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportModels.cs @@ -26,6 +26,14 @@ internal sealed record WorkspaceStaffOnboardingDataRightsExport( DateTimeOffset CreatedAtUtc, DateTimeOffset LastChangedAtUtc); +internal sealed record WorkspaceStaffDeferredClaimWithdrawalDataRightsExport( + Guid ClaimId, + string ScopeId, + Guid EnrollmentLinkId, + long ClaimVersion, + Guid EventId, + DateTimeOffset OccurredAtUtc); + internal sealed record WorkspaceStaffOnboardingCorrectionReceiptDataRightsExport( int ContractVersion, diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportSchema.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportSchema.cs index df4cb79f..5fc9d699 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportSchema.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportSchema.cs @@ -29,6 +29,7 @@ internal static class WorkspacesDataRightsExportSchema [ "access-control", "auth", + "messaging", "organizations", "properties", "staff", @@ -38,6 +39,7 @@ internal static class WorkspacesDataRightsExportSchema private static readonly Type[] SourceTypes = [ typeof(WorkspaceStaffOnboardingDataRightsExport), + typeof(WorkspaceStaffDeferredClaimWithdrawalDataRightsExport), typeof( WorkspaceStaffOnboardingCorrectionReceiptDataRightsExport), typeof( @@ -157,7 +159,9 @@ private static SchemaState Load() stream.CopyTo(buffer); PersonalDataCatalogDocument catalog = PersonalDataCatalogJson.Parse(buffer.ToArray()); - if (!string.Equals( + if (catalog.CatalogVersion != + WorkspacesTenantTerminationMetadata.PersonalDataCatalogVersion || + !string.Equals( catalog.CatalogId, "workspaces.personal-data", StringComparison.Ordinal) || diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesTenantTerminationExportContributor.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesTenantTerminationExportContributor.cs index 6d086b7f..4ee47a51 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesTenantTerminationExportContributor.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesTenantTerminationExportContributor.cs @@ -12,7 +12,7 @@ namespace BunkFy.Modules.Workspaces.Persistence.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using DomainFenceState = - BunkFy.Modules.Workspaces.Domain.Termination.WorkspaceTerminationFenceState; + Domain.Termination.WorkspaceTerminationFenceState; internal sealed class WorkspacesTenantTerminationExportContributor( WorkspacesDbContext dbContext, @@ -184,6 +184,35 @@ await WriteAsync( count = checked(count + 1); } + await foreach ( + WorkspaceStaffDeferredClaimWithdrawalDataRightsExport record in + dbContext.StaffDeferredClaimWithdrawals + .AsNoTracking() + .Where(item => item.ScopeId == tenantId) + .OrderBy(item => item.Id) + .Select(item => + new WorkspaceStaffDeferredClaimWithdrawalDataRightsExport( + item.Id, + item.ScopeId, + item.EnrollmentLinkId, + item.ClaimVersion, + item.EventId, + item.OccurredAtUtc)) + .AsAsyncEnumerable() + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + await WriteAsync( + WorkspacesDataRightsExportContributor + .StaffDeferredClaimWithdrawalRecordType, + record.ClaimId, + record.ClaimVersion, + record, + sink, + cancellationToken).ConfigureAwait(false); + count = checked(count + 1); + } + await foreach (WorkspaceStaffOnboardingCorrectionReceipt receipt in dbContext.StaffOnboardingCorrectionReceipts .AsNoTracking() diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/TenantTermination/WorkspaceTenantDestructionOwner.Batches.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/TenantTermination/WorkspaceTenantDestructionOwner.Batches.cs index d0d11c68..b01f8446 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/TenantTermination/WorkspaceTenantDestructionOwner.Batches.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/TenantTermination/WorkspaceTenantDestructionOwner.Batches.cs @@ -117,12 +117,9 @@ private Task RemoveCurrentStageAsync( process => process.Id, cancellationToken), WorkspaceTenantDestroyStage.OnboardingApplications => - this.RemoveGuidBatchAsync( + this.RemoveOnboardingStageBatchAsync( operation, - dbContext.StaffOnboardingApplications - .IgnoreQueryFilters() - .Where(application => application.ScopeId == tenantId), - application => application.Id, + tenantId, cancellationToken), WorkspaceTenantDestroyStage.RetentionCorrelationReceipts => this.RemoveGuidBatchAsync( @@ -193,6 +190,44 @@ private Task RemoveGuidBatchAsync( cancellationToken); } + private async Task RemoveOnboardingStageBatchAsync( + WorkspaceTenantDestroyOperation operation, + string tenantId, + CancellationToken cancellationToken) + { + WorkspaceStaffDeferredClaimWithdrawal[] loaded = await dbContext + .StaffDeferredClaimWithdrawals + .IgnoreQueryFilters() + .Where(withdrawal => withdrawal.ScopeId == tenantId) + .OrderBy(withdrawal => withdrawal.Id) + .Take(operation.BatchSize + 1) + .ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + if (loaded.Length > 0) + { + WorkspaceStaffDeferredClaimWithdrawal[] selected = loaded + .Take(operation.BatchSize) + .ToArray(); + dbContext.RemoveRange(selected); + EnsureBatchRecorded( + operation, + selected + .Select(withdrawal => $"deferred:{withdrawal.Id:N}") + .ToArray(), + stageCompleted: false, + clock.UtcNow); + return true; + } + + return await this.RemoveGuidBatchAsync( + operation, + dbContext.StaffOnboardingApplications + .IgnoreQueryFilters() + .Where(application => application.ScopeId == tenantId), + application => application.Id, + cancellationToken).ConfigureAwait(false); + } + private async Task RemoveBatchAsync( WorkspaceTenantDestroyOperation operation, IQueryable source, @@ -352,6 +387,10 @@ await dbContext.StaffOnboardingApplications .IgnoreQueryFilters() .AnyAsync(application => application.ScopeId == tenantId, cancellationToken) .ConfigureAwait(false) || + await dbContext.StaffDeferredClaimWithdrawals + .IgnoreQueryFilters() + .AnyAsync(withdrawal => withdrawal.ScopeId == tenantId, cancellationToken) + .ConfigureAwait(false) || await dbContext.StaffRetentionCorrelationReceipts .IgnoreQueryFilters() .AnyAsync(receipt => receipt.ScopeId == tenantId, cancellationToken) diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/WorkspacesDbContext.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/WorkspacesDbContext.cs index c23664b2..b38c0fbb 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/WorkspacesDbContext.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/WorkspacesDbContext.cs @@ -20,6 +20,9 @@ public sealed class WorkspacesDbContext( public DbSet StaffOnboardingApplications => this.Set(); + public DbSet + StaffDeferredClaimWithdrawals => + this.Set(); public DbSet StaffOnboardingCorrectionReceipts => this.Set(); diff --git a/src/Modules/Workspaces/docs/README.md b/src/Modules/Workspaces/docs/README.md index 4b99b58b..2e71caf4 100644 --- a/src/Modules/Workspaces/docs/README.md +++ b/src/Modules/Workspaces/docs/README.md @@ -67,6 +67,25 @@ redaction and reports an operational failure instead of deleting data when the authoritative history window has lapsed. Sensitive API and Admin API responses are explicitly non-cacheable. +Withdrawal observations that arrive before the corresponding claim-request +event are retained in a bounded Workspaces-owned correlation table. Claim +request binding and withdrawal consumption share one exclusive source +coordinate, so neither subscription can miss the other's commit. Exact +duplicates replay, divergent coordinates fail closed, and the observation is +removed only after authoritative terminalization, source terminal cleanup, or +tenant destruction. This v12 persistence/export shape advances both the +personal-data catalogue and tenant-termination manifest; rollout evidence and +frozen owner approvals must use the new catalog version and digest. +Organizations admission permits a claim on a BunkFy-owned link only while a +matching admissible Workspaces application exists. The table is lifecycle- +bounded by link terminalization and tenant destruction, but it is not count- +bounded by `MaximumClaims`: withdrawal releases that concurrent reservation. +Operational capacity is therefore the link lifetime multiplied by admitted +claim rate and worst-case claim-request consumer lag. Source cleanup uses one +set-based delete inside the serialized transaction; rollout must load-test that +duration/rate envelope within the 30-second consumer timeout and alert on +deferred-row age and count while a source remains active. + Automatic Staff retention also asks Workspaces to close access and remove the departed person's Auth subject from terminal onboarding and access history. Workspaces blocks while a person-linked onboarding or access workflow is @@ -111,6 +130,21 @@ provisioning. Workspaces records an append-only replay receipt, exports its bounded accountability proof without the request fingerprint, and completes the central case through its own durable outbox. +Enrollment-link resubmission and correction POST recheck the authoritative +Organizations claim while holding the existing source/application lease. +Only no retained claim or one exact-coordinate `Pending` claim with a strictly +future decision deadline remains editable. A retained `Pending` claim with a +missing or elapsed deadline, or unknown, terminal, or coordinate-mismatched +authority, returns conflict without changing staged profile data. Ordinary +invitation resubmission still relies on the local +invitation-token lifecycle, but an approved invitation Data Rights +correction has only the local `Submitted`/version fence because the current +Organizations contracts do not publish an invitation-status inspector. The +correction-target GET also remains an optimistic local preview; the +enrollment-link correction POST is the authoritative external fence. A future +invitation inspector must use the same lease ordering before closing that +residual; it must not introduce a Staff-to-Workspaces reverse lock edge. + An approved Staff Rights restriction may independently suspend ordinary processing of one exact `staff-onboarding` record while Workspaces still owns its applicant data. Operational reads, resubmission, admission, actionable diff --git a/src/Modules/Workspaces/docs/personal-data-catalog.v1.json b/src/Modules/Workspaces/docs/personal-data-catalog.v1.json index c46a680c..9412cdd3 100644 --- a/src/Modules/Workspaces/docs/personal-data-catalog.v1.json +++ b/src/Modules/Workspaces/docs/personal-data-catalog.v1.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "catalogId": "workspaces.personal-data", - "catalogVersion": 11, + "catalogVersion": 12, "module": "workspaces", "approvalState": "engineering-default", "accessPolicies": [ @@ -190,6 +190,13 @@ "endsAt": "protected-export-assembly-completed-or-failed", "legalHoldBehavior": "not-applicable" }, + { + "id": "workspace-deferred-claim-withdrawal", + "approvalState": "engineering-default", + "startsAt": "withdrawal-observed-before-claim-correlation", + "endsAt": "withdrawal-consumed-or-source-terminalized-or-tenant-termination", + "legalHoldBehavior": "retain-only-until-terminal-correlation" + }, { "id": "workspace-access-history", "approvalState": "engineering-default", @@ -1732,6 +1739,20 @@ "member": "ClaimId", "surface": "data-rights-export", "retentionPolicy": "workspaces-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal", + "member": "Id", + "surface": "persistence", + "retentionPolicy": "workspace-deferred-claim-withdrawal" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffDeferredClaimWithdrawalDataRightsExport", + "member": "ClaimId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" } ] }, @@ -1848,6 +1869,20 @@ "member": "ClaimVersion", "surface": "data-rights-export", "retentionPolicy": "workspaces-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal", + "member": "ClaimVersion", + "surface": "persistence", + "retentionPolicy": "workspace-deferred-claim-withdrawal" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffDeferredClaimWithdrawalDataRightsExport", + "member": "ClaimVersion", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" } ] }, @@ -1870,10 +1905,13 @@ "retentionPolicy": "integration-message-journal", "rightsPolicy": "audit-attribution", "allowedSurfaces": [ - "integration-event" + "data-rights-export", + "integration-event", + "persistence" ], "allowedBoundaries": [ - "cross-module" + "cross-module", + "intra-module" ], "approvalState": "engineering-default", "bindings": [ @@ -1939,6 +1977,20 @@ "member": "EventId", "surface": "integration-event", "retentionPolicy": "integration-message-journal" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal", + "member": "EventId", + "surface": "persistence", + "retentionPolicy": "workspace-deferred-claim-withdrawal" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffDeferredClaimWithdrawalDataRightsExport", + "member": "EventId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" } ] }, @@ -1961,10 +2013,13 @@ "retentionPolicy": "integration-message-journal", "rightsPolicy": "audit-attribution", "allowedSurfaces": [ - "integration-event" + "data-rights-export", + "integration-event", + "persistence" ], "allowedBoundaries": [ - "cross-module" + "cross-module", + "intra-module" ], "approvalState": "engineering-default", "bindings": [ @@ -2030,6 +2085,20 @@ "member": "OccurredAtUtc", "surface": "integration-event", "retentionPolicy": "integration-message-journal" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal", + "member": "OccurredAtUtc", + "surface": "persistence", + "retentionPolicy": "workspace-deferred-claim-withdrawal" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffDeferredClaimWithdrawalDataRightsExport", + "member": "OccurredAtUtc", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" } ] }, @@ -2593,6 +2662,20 @@ "member": "PlanId", "surface": "data-rights-export", "retentionPolicy": "workspaces-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal", + "member": "EnrollmentLinkId", + "surface": "persistence", + "retentionPolicy": "workspace-deferred-claim-withdrawal" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffDeferredClaimWithdrawalDataRightsExport", + "member": "EnrollmentLinkId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" } ] }, @@ -4922,6 +5005,27 @@ "member": "ScopeId", "surface": "data-rights-export", "retentionPolicy": "workspaces-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal", + "member": "ScopeId", + "surface": "persistence", + "retentionPolicy": "workspace-deferred-claim-withdrawal" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal", + "member": "OrganizationId", + "surface": "persistence", + "retentionPolicy": "workspace-deferred-claim-withdrawal" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffDeferredClaimWithdrawalDataRightsExport", + "member": "ScopeId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" } ] }, diff --git a/src/Modules/Workspaces/docs/personal-data-inventory.v1.md b/src/Modules/Workspaces/docs/personal-data-inventory.v1.md index 8e668e22..34be20eb 100644 --- a/src/Modules/Workspaces/docs/personal-data-inventory.v1.md +++ b/src/Modules/Workspaces/docs/personal-data-inventory.v1.md @@ -1,4 +1,4 @@ -# workspaces Personal-Data Inventory v11 +# workspaces Personal-Data Inventory v12 Generated from `workspaces.personal-data` schema v1. Catalogue approval: `engineering-default`. @@ -33,6 +33,7 @@ Engineering metadata is not legal or country-launch approval. | workspace-access-history | engineering-default | access-record-created | approved-erasure-or-employment-retention-completed | pause-approved-erasure | | workspace-data-rights-anonymisation-proof | engineering-default | workspace-staff-correlation-anonymisation-completed | approved-owner-proof-retention-completed-or-tenant-termination | pause-approved-disposal | | workspace-data-rights-correction-proof | engineering-default | workspace-onboarding-correction-completed | approved-workspace-correction-proof-retention-completed | pause-approved-erasure | +| workspace-deferred-claim-withdrawal | engineering-default | withdrawal-observed-before-claim-correlation | withdrawal-consumed-or-source-terminalized-or-tenant-termination | retain-only-until-terminal-correlation | | workspace-onboarding-applicant-copy | engineering-default | onboarding-submitted | onboarding-terminal-or-source-expired-profile-redaction | retain-only-approved-minimum | | workspace-onboarding-lifecycle | engineering-default | onboarding-submitted | approved-erasure-or-employment-retention-completed | pause-approved-erasure | | workspace-onboarding-processing-restriction-lifecycle | engineering-default | workspace-staff-onboarding-created | approved-erasure-or-tenant-termination | pause-approved-erasure | @@ -102,8 +103,8 @@ Engineering metadata is not legal or country-launch approval. | workspaces.enrollment-claim-id | staff | pseudonymous-identifier | standard | approval-tracking
claim-correlation | organizations | organizations | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.enrollment-claim-id | workspace-onboarding-lifecycle | staff-employment-history | api-response
data-rights-export
integration-event
persistence | cross-module
customer-api
intra-module | engineering-default | | workspaces.enrollment-claim-state | staff | lifecycle | standard | claim-approval
onboarding-reaction | organizations | organizations | customer-controller-bunk-fy-processor | workspaces-event-input | workspaces.enrollment-claim-state | integration-message-journal | staff-employment-history | integration-event | cross-module | engineering-default | | workspaces.enrollment-claim-version | staff | lifecycle | standard | approval-tracking
claim-concurrency | organizations | organizations | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.enrollment-claim-version | workspace-onboarding-lifecycle | staff-employment-history | api-response
data-rights-export
integration-event
persistence | cross-module
customer-api
intra-module | engineering-default | -| workspaces.integration-event-id | staff | pseudonymous-identifier | standard | audit-correlation
message-idempotency | messaging-runtime | messaging | customer-controller-bunk-fy-processor | workspaces-event-input | workspaces.integration-event-id | integration-message-journal | audit-attribution | integration-event | cross-module | engineering-default | -| workspaces.integration-event-occurred-at | staff | lifecycle | standard | audit-correlation
message-ordering | messaging-runtime | messaging | customer-controller-bunk-fy-processor | workspaces-event-input | workspaces.integration-event-occurred-at | integration-message-journal | audit-attribution | integration-event | cross-module | engineering-default | +| workspaces.integration-event-id | staff | pseudonymous-identifier | standard | audit-correlation
message-idempotency | messaging-runtime | messaging | customer-controller-bunk-fy-processor | workspaces-event-input | workspaces.integration-event-id | integration-message-journal | audit-attribution | data-rights-export
integration-event
persistence | cross-module
intra-module | engineering-default | +| workspaces.integration-event-occurred-at | staff | lifecycle | standard | audit-correlation
message-ordering | messaging-runtime | messaging | customer-controller-bunk-fy-processor | workspaces-event-input | workspaces.integration-event-occurred-at | integration-message-journal | audit-attribution | data-rights-export
integration-event
persistence | cross-module
intra-module | engineering-default | | workspaces.invitation-recipient-email | staff | contact | elevated | invitation-delivery
recipient-restriction | organizations
workspace-operator | organizations | customer-controller-bunk-fy-processor | workspaces-sensitive | workspaces.invitation-recipient-email | join-source-lifecycle | onboarding-editable | api-input
api-response
application-command | customer-api
intra-module | engineering-default | | workspaces.join-source-already-issued | staff | lifecycle | standard | idempotent-issuance | organizations | organizations | customer-controller-bunk-fy-processor | workspaces-join-source | workspaces.join-source-already-issued | transient-response | staff-employment-history | api-response | customer-api | engineering-default | | workspaces.join-source-approval-mode | staff | linked-operational | standard | admission-safety
enrollment-approval | organizations
workspace-operator | organizations | customer-controller-bunk-fy-processor | workspaces-join-source | workspaces.join-source-approval-mode | join-source-lifecycle | staff-employment-history | api-input
api-response
application-command | customer-api
intra-module | engineering-default | @@ -364,7 +365,9 @@ Engineering metadata is not legal or country-launch approval. | workspaces.data-rights.tenant-scope-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingCorrectionAppliedDomainEvent | ScopeId | domain-event | transient-request | | workspaces.enrollment-claim-decision-expires-at | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimExpiredIntegrationEvent | DecisionExpiresAtUtc | integration-event | integration-message-journal | | workspaces.enrollment-claim-id | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingDto | ClaimId | api-response | transient-response | +| workspaces.enrollment-claim-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal | Id | persistence | workspace-deferred-claim-withdrawal | | workspaces.enrollment-claim-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | ClaimId | persistence | workspace-onboarding-lifecycle | +| workspaces.enrollment-claim-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffDeferredClaimWithdrawalDataRightsExport | ClaimId | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.enrollment-claim-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | ClaimId | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.enrollment-claim-id | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimChangedIntegrationEvent | ClaimId | integration-event | integration-message-journal | | workspaces.enrollment-claim-id | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimExpiredIntegrationEvent | ClaimId | integration-event | integration-message-journal | @@ -372,12 +375,16 @@ Engineering metadata is not legal or country-launch approval. | workspaces.enrollment-claim-state | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimChangedIntegrationEvent | Change | integration-event | integration-message-journal | | workspaces.enrollment-claim-state | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimChangedIntegrationEvent | Status | integration-event | integration-message-journal | | workspaces.enrollment-claim-version | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingDto | ClaimVersion | api-response | transient-response | +| workspaces.enrollment-claim-version | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal | ClaimVersion | persistence | workspace-deferred-claim-withdrawal | | workspaces.enrollment-claim-version | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | ClaimVersion | persistence | workspace-onboarding-lifecycle | +| workspaces.enrollment-claim-version | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffDeferredClaimWithdrawalDataRightsExport | ClaimVersion | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.enrollment-claim-version | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | ClaimVersion | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.enrollment-claim-version | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimChangedIntegrationEvent | ClaimVersion | integration-event | integration-message-journal | | workspaces.enrollment-claim-version | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimExpiredIntegrationEvent | ClaimVersion | integration-event | integration-message-journal | | workspaces.enrollment-claim-version | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimWithdrawnIntegrationEvent | ClaimVersion | integration-event | integration-message-journal | | workspaces.integration-event-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffMemberLifecycleChangedIntegrationEvent | EventId | integration-event | integration-message-journal | +| workspaces.integration-event-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal | EventId | persistence | workspace-deferred-claim-withdrawal | +| workspaces.integration-event-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffDeferredClaimWithdrawalDataRightsExport | EventId | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.integration-event-id | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimChangedIntegrationEvent | EventId | integration-event | integration-message-journal | | workspaces.integration-event-id | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimExpiredIntegrationEvent | EventId | integration-event | integration-message-journal | | workspaces.integration-event-id | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimWithdrawnIntegrationEvent | EventId | integration-event | integration-message-journal | @@ -387,6 +394,8 @@ Engineering metadata is not legal or country-launch approval. | workspaces.integration-event-id | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationInvitationExpiredIntegrationEvent | EventId | integration-event | integration-message-journal | | workspaces.integration-event-id | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationMembershipChangedIntegrationEvent | EventId | integration-event | integration-message-journal | | workspaces.integration-event-occurred-at | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffMemberLifecycleChangedIntegrationEvent | OccurredAtUtc | integration-event | integration-message-journal | +| workspaces.integration-event-occurred-at | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal | OccurredAtUtc | persistence | workspace-deferred-claim-withdrawal | +| workspaces.integration-event-occurred-at | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffDeferredClaimWithdrawalDataRightsExport | OccurredAtUtc | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.integration-event-occurred-at | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimChangedIntegrationEvent | OccurredAtUtc | integration-event | integration-message-journal | | workspaces.integration-event-occurred-at | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimExpiredIntegrationEvent | OccurredAtUtc | integration-event | integration-message-journal | | workspaces.integration-event-occurred-at | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimWithdrawnIntegrationEvent | OccurredAtUtc | integration-event | integration-message-journal | @@ -429,9 +438,11 @@ Engineering metadata is not legal or country-launch approval. | workspaces.join-source-id | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingDto | SourceId | api-response | transient-response | | workspaces.join-source-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessPlan | Id | persistence | workspace-access-history | | workspaces.join-source-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessPlanProperty | PlanId | persistence | workspace-access-history | +| workspaces.join-source-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal | EnrollmentLinkId | persistence | workspace-deferred-claim-withdrawal | | workspaces.join-source-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | SourceId | persistence | workspace-access-history | | workspaces.join-source-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffAccessPlanDataRightsExport | Id | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.join-source-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffAccessPlanPropertyDataRightsExport | PlanId | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.join-source-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffDeferredClaimWithdrawalDataRightsExport | EnrollmentLinkId | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.join-source-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | SourceId | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.join-source-id | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimChangedIntegrationEvent | EnrollmentLinkId | integration-event | integration-message-journal | | workspaces.join-source-id | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimExpiredIntegrationEvent | EnrollmentLinkId | integration-event | integration-message-journal | @@ -937,11 +948,14 @@ Engineering metadata is not legal or country-launch approval. | workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessPlan | ScopeId | persistence | workspace-access-history | | workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessPlanProperty | ScopeId | persistence | workspace-access-history | | workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessProcess | ScopeId | persistence | workspace-access-history | +| workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal | OrganizationId | persistence | workspace-deferred-claim-withdrawal | +| workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffDeferredClaimWithdrawal | ScopeId | persistence | workspace-deferred-claim-withdrawal | | workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | ScopeId | persistence | workspace-access-history | | workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffRetentionCorrelationReceipt | ScopeId | persistence | workspace-access-history | | workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffAccessPlanDataRightsExport | ScopeId | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffAccessPlanPropertyDataRightsExport | ScopeId | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffAccessProcessDataRightsExport | ScopeId | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffDeferredClaimWithdrawalDataRightsExport | ScopeId | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | ScopeId | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffRetentionCorrelationDataRightsExport | ScopeId | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.workspace-scope-id | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationEnrollmentClaimChangedIntegrationEvent | OrganizationId | integration-event | integration-message-journal | diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Api/WorkspacesApiSecurityTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Api/WorkspacesApiSecurityTests.cs index 7792b7ec..e86ee8a6 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Api/WorkspacesApiSecurityTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Api/WorkspacesApiSecurityTests.cs @@ -10,6 +10,7 @@ namespace BunkFy.Modules.Workspaces.Tests.Api; using Gma.Framework.AccessControl; using Gma.Framework.AccessControl.AspNetCore; using Gma.Framework.Administration.Api; +using Gma.Framework.Api.Results; using Gma.Framework.Cqrs; using Gma.Framework.Scoping; using Gma.Modules.AccessControl.Contracts; @@ -23,6 +24,24 @@ namespace BunkFy.Modules.Workspaces.Tests.Api; [Trait("Category", "Unit")] public sealed class WorkspacesApiSecurityTests { + [Fact] + public void Profile_mutation_authority_failure_maps_to_conflict() + { + Type apiSupport = typeof(WorkspacesModule).Assembly.GetType( + "BunkFy.Modules.Workspaces.Api.WorkspacesApiEndpointSupport", + throwOnError: true)!; + ApiErrorStatusCodeMap mappings = + (ApiErrorStatusCodeMap)apiSupport.GetField( + "ErrorStatusCodes", + BindingFlags.Public | BindingFlags.Static)!.GetValue(null)!; + + Assert.Equal( + StatusCodes.Status409Conflict, + mappings.GetStatusCode( + WorkspaceStaffOnboardingApplicationErrors + .ProfileMutationAuthorityUnavailable)); + } + [Fact] public void Sensitive_response_policies_disable_storage() { diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandlerTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandlerTests.cs index 1df5c22f..6219ff05 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandlerTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandlerTests.cs @@ -14,6 +14,7 @@ namespace BunkFy.Modules.Workspaces.Tests; using Gma.Framework.Runtime.Identity; using Gma.Framework.Runtime.Time; using Gma.Framework.Scoping; +using Gma.Modules.Organizations.Contracts; using Xunit; [Trait("Category", "Unit")] @@ -145,6 +146,247 @@ await handler.HandleAsync( Assert.Equal(1, correctionLock.AcquisitionCount); } + [Theory] + [InlineData(OrganizationEnrollmentClaimStatus.Unknown)] + [InlineData(OrganizationEnrollmentClaimStatus.Accepted)] + [InlineData(OrganizationEnrollmentClaimStatus.Rejected)] + [InlineData(OrganizationEnrollmentClaimStatus.Expired)] + [InlineData(OrganizationEnrollmentClaimStatus.Withdrawn)] + public async Task Terminal_or_unknown_enrollment_claim_rejects_profile_correction( + OrganizationEnrollmentClaimStatus status) + { + WorkspaceStaffOnboarding application = CreateApplication(); + FakeOrganizationEnrollmentClaimInspector claims = new( + Claim(application, status)); + ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler handler = + CreateHandler( + application, + new InMemoryReceiptRepository(), + new RecordingCorrectionLock(), + new RecordingExecutionGate(), + claims); + + Result result = + await handler.HandleAsync( + Command(application) with { DisplayName = "Changed" }, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors + .CorrectionTargetUnavailable, + result.Error); + Assert.Equal("Ada Operator", application.DisplayName); + Assert.Single(claims.Requests); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Missing_or_exact_pending_enrollment_claim_allows_profile_correction( + bool hasPendingClaim) + { + WorkspaceStaffOnboarding application = CreateApplication(); + FakeOrganizationEnrollmentClaimInspector claims = new( + hasPendingClaim + ? Claim( + application, + OrganizationEnrollmentClaimStatus.Pending) + : null); + ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler handler = + CreateHandler( + application, + new InMemoryReceiptRepository(), + new RecordingCorrectionLock(), + new RecordingExecutionGate(), + claims); + + Result result = + await handler.HandleAsync( + Command(application) with { DisplayName = "Changed" }, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal("Changed", application.DisplayName); + Assert.Single(claims.Requests); + } + + [Theory] + [InlineData(-10, false)] + [InlineData(0, false)] + [InlineData(9, false)] + [InlineData(10, true)] + public async Task Pending_claim_deadline_uses_strict_persistence_precision_boundary( + long deadlineTicksFromNow, + bool expectedAllowed) + { + WorkspaceStaffOnboarding application = CreateApplication(); + OrganizationEnrollmentClaimDto claim = Claim( + application, + OrganizationEnrollmentClaimStatus.Pending) with + { + DecisionExpiresAtUtc = Now.AddTicks(deadlineTicksFromNow) + }; + FakeOrganizationEnrollmentClaimInspector claims = new(claim); + ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler handler = + CreateHandler( + application, + new InMemoryReceiptRepository(), + new RecordingCorrectionLock(), + new RecordingExecutionGate(), + claims); + + Result result = + await handler.HandleAsync( + Command(application) with { DisplayName = "Changed" }, + CancellationToken.None); + + Assert.Equal(expectedAllowed, result.IsSuccess); + Assert.Equal( + expectedAllowed ? "Changed" : "Ada Operator", + application.DisplayName); + if (!expectedAllowed) + { + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors + .CorrectionTargetUnavailable, + result.Error); + } + } + + [Fact] + public async Task Pending_claim_without_decision_deadline_rejects_profile_correction() + { + WorkspaceStaffOnboarding application = CreateApplication(); + OrganizationEnrollmentClaimDto claim = Claim( + application, + OrganizationEnrollmentClaimStatus.Pending) with + { + DecisionExpiresAtUtc = null + }; + ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler handler = + CreateHandler( + application, + new InMemoryReceiptRepository(), + new RecordingCorrectionLock(), + new RecordingExecutionGate(), + new FakeOrganizationEnrollmentClaimInspector(claim)); + + Result result = + await handler.HandleAsync( + Command(application) with { DisplayName = "Changed" }, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors + .CorrectionTargetUnavailable, + result.Error); + Assert.Equal("Ada Operator", application.DisplayName); + } + + [Theory] + [InlineData("organization")] + [InlineData("source")] + [InlineData("subject")] + public async Task Mismatched_pending_enrollment_claim_rejects_profile_correction( + string coordinate) + { + WorkspaceStaffOnboarding application = CreateApplication(); + OrganizationEnrollmentClaimDto claim = Claim( + application, + OrganizationEnrollmentClaimStatus.Pending); + claim = coordinate switch + { + "organization" => claim with { OrganizationId = Guid.NewGuid() }, + "source" => claim with { EnrollmentLinkId = Guid.NewGuid() }, + _ => claim with { SubjectId = "subject:other" } + }; + FakeOrganizationEnrollmentClaimInspector claims = new(claim); + ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler handler = + CreateHandler( + application, + new InMemoryReceiptRepository(), + new RecordingCorrectionLock(), + new RecordingExecutionGate(), + claims); + + Result result = + await handler.HandleAsync( + Command(application) with { DisplayName = "Changed" }, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors + .CorrectionTargetUnavailable, + result.Error); + Assert.Equal("Ada Operator", application.DisplayName); + Assert.Single(claims.Requests); + } + + [Fact] + public async Task Invitation_profile_correction_does_not_query_enrollment_claims() + { + WorkspaceStaffOnboarding application = CreateApplication( + WorkspaceStaffOnboardingSource.Invitation); + FakeOrganizationEnrollmentClaimInspector claims = new( + Claim(application, OrganizationEnrollmentClaimStatus.Accepted)); + ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler handler = + CreateHandler( + application, + new InMemoryReceiptRepository(), + new RecordingCorrectionLock(), + new RecordingExecutionGate(), + claims); + + Result result = + await handler.HandleAsync( + Command(application) with { DisplayName = "Changed" }, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal("Changed", application.DisplayName); + Assert.Empty(claims.Requests); + } + + [Fact] + public async Task Version_and_local_status_errors_precede_external_claim_fence() + { + WorkspaceStaffOnboarding application = CreateApplication(); + FakeOrganizationEnrollmentClaimInspector claims = new( + Claim(application, OrganizationEnrollmentClaimStatus.Accepted)); + ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler handler = + CreateHandler( + application, + new InMemoryReceiptRepository(), + new RecordingCorrectionLock(), + new RecordingExecutionGate(), + claims); + + Result stale = + await handler.HandleAsync( + Command(application) with + { + ExpectedVersion = application.Version + 1, + DisplayName = "Changed" + }, + CancellationToken.None); + Assert.True(application.ObserveClaimRequested( + Guid.NewGuid(), + claimVersion: 1, + Now.AddMinutes(1)).IsSuccess); + Result reviewed = + await handler.HandleAsync( + Command(application) with { DisplayName = "Changed" }, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffOnboardingErrors.CorrectionVersionConflict, + stale.Error); + Assert.Equal( + WorkspaceStaffOnboardingErrors.CorrectionUnavailable, + reviewed.Error); + Assert.Empty(claims.Requests); + } + [Fact] public async Task Receipt_committed_while_waiting_for_lock_is_replayed() { @@ -274,7 +516,8 @@ private static WorkspaceStaffOnboarding application, InMemoryReceiptRepository receipts, RecordingCorrectionLock correctionLock, - RecordingExecutionGate gate) + RecordingExecutionGate gate, + FakeOrganizationEnrollmentClaimInspector? claims = null) { InMemoryApplicationRepository applications = new(application); return new( @@ -285,6 +528,7 @@ private static new WorkspaceStaffOnboardingDataRightsCorrectionAuthorizer( gate, new TestScopeContext()), + claims ?? new FakeOrganizationEnrollmentClaimInspector(), new TestScopeContext(), new TestClock(), new SequenceIdGenerator()); @@ -307,11 +551,13 @@ private static ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommand application.Department, "user:privacy-owner"); - private static WorkspaceStaffOnboarding CreateApplication() => + private static WorkspaceStaffOnboarding CreateApplication( + WorkspaceStaffOnboardingSource sourceKind = + WorkspaceStaffOnboardingSource.EnrollmentLink) => WorkspaceStaffOnboarding.Create( Guid.Parse("40000000-0000-0000-0000-000000000001"), TenantId, - WorkspaceStaffOnboardingSource.EnrollmentLink, + sourceKind, Guid.Parse("50000000-0000-0000-0000-000000000001"), "subject:applicant", "verified@example.test", @@ -324,6 +570,27 @@ private static WorkspaceStaffOnboarding CreateApplication() => "Operations", Now.AddHours(-1)).Value; + private static OrganizationEnrollmentClaimDto Claim( + WorkspaceStaffOnboarding application, + OrganizationEnrollmentClaimStatus status) => new( + Guid.Parse("60000000-0000-0000-0000-000000000001"), + application.SourceId, + Guid.Parse(TenantId), + application.SubjectId, + status, + status == OrganizationEnrollmentClaimStatus.Accepted + ? Guid.Parse("70000000-0000-0000-0000-000000000001") + : null, + Version: 2, + Now, + Now.AddMinutes(1)) + { + DecisionExpiresAtUtc = + status == OrganizationEnrollmentClaimStatus.Pending + ? Now.AddMinutes(5) + : null + }; + private sealed class RecordingExecutionGate(bool allowed = true) : IDataRightsCorrectionExecutionGate { diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/OrganizationStaffOnboardingExpiryHandlerTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/OrganizationStaffOnboardingExpiryHandlerTests.cs index d8bea8db..cc538b42 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/OrganizationStaffOnboardingExpiryHandlerTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/OrganizationStaffOnboardingExpiryHandlerTests.cs @@ -56,9 +56,19 @@ public async Task Claim_expiry_after_link_expiry_terminates_the_application_and_ application.SourceId); FakeOnboardingRepository applications = new(application); FakeAccessPlanRepository plans = new(plan); + FakeWorkspaceStaffDeferredClaimWithdrawalRepository deferred = new( + WorkspaceStaffDeferredClaimWithdrawal.Create( + ScopeId, + OrganizationId, + application.SourceId, + Guid.NewGuid(), + 2, + Guid.NewGuid(), + Now).Value); OrganizationEnrollmentLinkExpiredStaffOnboardingHandler linkHandler = new( applications, plans, + deferred, WorkspaceStaffOnboardingMutationTestSupport.Create(applications), new FakeClock()); await linkHandler.HandleAsync( @@ -71,10 +81,12 @@ await linkHandler.HandleAsync( Now, 2), CancellationToken.None); + Assert.Single(deferred.Items); OrganizationEnrollmentClaimExpiredStaffOnboardingHandler handler = new( applications, plans, + deferred, WorkspaceStaffOnboardingMutationTestSupport.Create(applications), new FakeClock()); OrganizationEnrollmentClaimExpiredIntegrationEvent integrationEvent = new( @@ -97,6 +109,7 @@ await linkHandler.HandleAsync( Assert.Equal(Now, plan.SourceExpiredAtUtc); Assert.Equal(applicationVersion, application.Version); Assert.Equal(planVersion, plan.Version); + Assert.Empty(deferred.Items); Assert.Null(application.VerifiedAccountEmail); Assert.Null(application.DisplayName); } @@ -114,6 +127,7 @@ public async Task Claim_expiry_preserves_the_reusable_plan_while_its_link_is_act OrganizationEnrollmentClaimExpiredStaffOnboardingHandler handler = new( applications, new FakeAccessPlanRepository(plan), + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository(), WorkspaceStaffOnboardingMutationTestSupport.Create(applications), new FakeClock()); @@ -147,6 +161,7 @@ public async Task Claim_withdrawal_terminates_staging_once_and_preserves_a_reusa OrganizationEnrollmentClaimWithdrawnStaffOnboardingHandler handler = new( applications, new FakeAccessPlanRepository(plan), + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository(), WorkspaceStaffOnboardingMutationTestSupport.Create(applications), new FakeClock()); OrganizationEnrollmentClaimWithdrawnIntegrationEvent integrationEvent = new( @@ -171,16 +186,17 @@ public async Task Claim_withdrawal_terminates_staging_once_and_preserves_a_reusa } [Fact] - public async Task Claim_withdrawal_without_its_application_is_retried_by_the_inbox() + public async Task Unowned_claim_withdrawal_without_product_state_is_acknowledged() { FakeOnboardingRepository applications = new(); OrganizationEnrollmentClaimWithdrawnStaffOnboardingHandler handler = new( applications, new FakeAccessPlanRepository(), + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository(), WorkspaceStaffOnboardingMutationTestSupport.Create(applications), new FakeClock()); - await Assert.ThrowsAsync(() => handler.HandleAsync( + await handler.HandleAsync( new OrganizationEnrollmentClaimWithdrawnIntegrationEvent( Guid.NewGuid(), Now.AddMinutes(1), @@ -189,20 +205,195 @@ await Assert.ThrowsAsync(() => handler.HandleAsync( Guid.NewGuid(), Guid.NewGuid(), 2), + CancellationToken.None); + } + + [Fact] + public async Task Withdrawal_before_requested_is_durable_exactly_replayable_and_timestamp_stable() + { + WorkspaceStaffOnboarding application = WorkspaceStaffOnboardingTests.CreateApplication(); + WorkspaceStaffAccessPlan plan = CreateActivePlan( + WorkspaceStaffOnboardingSource.EnrollmentLink, + application.SourceId); + FakeOnboardingRepository applications = new(application); + FakeWorkspaceStaffDeferredClaimWithdrawalRepository deferred = new(); + OrganizationEnrollmentClaimWithdrawnStaffOnboardingHandler handler = new( + applications, + new FakeAccessPlanRepository(plan), + deferred, + WorkspaceStaffOnboardingMutationTestSupport.Create(applications), + new FakeClock()); + Guid claimId = Guid.NewGuid(); + Guid eventId = Guid.NewGuid(); + DateTimeOffset occurredAtUtc = Now.AddMinutes(1).AddTicks(1); + OrganizationEnrollmentClaimWithdrawnIntegrationEvent integrationEvent = new( + eventId, + occurredAtUtc, + ScopeId, + OrganizationId, + application.SourceId, + claimId, + 2); + + await handler.HandleAsync(integrationEvent, CancellationToken.None); + await handler.HandleAsync(integrationEvent, CancellationToken.None); + + WorkspaceStaffDeferredClaimWithdrawal persisted = Assert.Single(deferred.Items); + Assert.Equal(Now.AddMinutes(1), persisted.OccurredAtUtc); + Assert.True(persisted.Matches( + ScopeId, + OrganizationId, + application.SourceId, + claimId, + 2, + eventId, + occurredAtUtc)); + await Assert.ThrowsAsync(() => handler.HandleAsync( + new OrganizationEnrollmentClaimWithdrawnIntegrationEvent( + Guid.NewGuid(), + occurredAtUtc, + ScopeId, + OrganizationId, + application.SourceId, + claimId, + 2), CancellationToken.None)); + Assert.Single(deferred.Items); } [Fact] - public async Task Claim_expiry_without_its_application_is_retried_by_the_inbox() + public async Task Late_withdrawal_does_not_recreate_state_for_a_terminal_plan() + { + Guid sourceId = Guid.NewGuid(); + WorkspaceStaffAccessPlan plan = CreateActivePlan( + WorkspaceStaffOnboardingSource.EnrollmentLink, + sourceId); + Assert.True(plan.Supersede(Now.AddMinutes(1)).IsSuccess); + FakeOnboardingRepository applications = new(); + FakeWorkspaceStaffDeferredClaimWithdrawalRepository deferred = new(); + OrganizationEnrollmentClaimWithdrawnStaffOnboardingHandler handler = new( + applications, + new FakeAccessPlanRepository(plan), + deferred, + WorkspaceStaffOnboardingMutationTestSupport.Create(applications), + new FakeClock()); + + await handler.HandleAsync( + new OrganizationEnrollmentClaimWithdrawnIntegrationEvent( + Guid.NewGuid(), + Now.AddMinutes(2), + ScopeId, + OrganizationId, + sourceId, + Guid.NewGuid(), + 2), + CancellationToken.None); + + Assert.Empty(deferred.Items); + } + + [Fact] + public async Task Terminal_plan_with_an_active_application_is_retried_as_an_invariant_breach() + { + WorkspaceStaffOnboarding application = WorkspaceStaffOnboardingTests.CreateApplication(); + WorkspaceStaffAccessPlan plan = CreateActivePlan( + WorkspaceStaffOnboardingSource.EnrollmentLink, + application.SourceId); + Assert.True(plan.Supersede(Now.AddMinutes(1)).IsSuccess); + FakeOnboardingRepository applications = new(application); + OrganizationEnrollmentClaimWithdrawnStaffOnboardingHandler handler = new( + applications, + new FakeAccessPlanRepository(plan), + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository(), + WorkspaceStaffOnboardingMutationTestSupport.Create(applications), + new FakeClock()); + + await Assert.ThrowsAsync(() => handler.HandleAsync( + new OrganizationEnrollmentClaimWithdrawnIntegrationEvent( + Guid.NewGuid(), + Now.AddMinutes(2), + ScopeId, + OrganizationId, + application.SourceId, + Guid.NewGuid(), + 2), + CancellationToken.None)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Delayed_withdrawal_cleans_its_exact_fact_without_regressing_a_newer_terminal_state( + bool expire) + { + WorkspaceStaffOnboarding application = WorkspaceStaffOnboardingTests.CreateApplication(); + Guid claimId = Guid.NewGuid(); + Assert.True(application.ObserveClaimRequested(claimId, 1, Now).IsSuccess); + if (expire) + { + Assert.True(application.ObserveClaimExpired( + claimId, + 2, + Now.AddMinutes(1)).IsSuccess); + } + else + { + Assert.True(application.Supersede(Now.AddMinutes(1)).IsSuccess); + } + + WorkspaceStaffOnboardingState expected = application.Status; + long expectedVersion = application.Version; + WorkspaceStaffAccessPlan plan = CreateActivePlan( + WorkspaceStaffOnboardingSource.EnrollmentLink, + application.SourceId); + Guid eventId = Guid.NewGuid(); + DateTimeOffset occurredAtUtc = Now.AddMinutes(2); + WorkspaceStaffDeferredClaimWithdrawal observed = + WorkspaceStaffDeferredClaimWithdrawal.Create( + ScopeId, + OrganizationId, + application.SourceId, + claimId, + 3, + eventId, + occurredAtUtc).Value; + FakeWorkspaceStaffDeferredClaimWithdrawalRepository deferred = new(observed); + FakeOnboardingRepository applications = new(application); + OrganizationEnrollmentClaimWithdrawnStaffOnboardingHandler handler = new( + applications, + new FakeAccessPlanRepository(plan), + deferred, + WorkspaceStaffOnboardingMutationTestSupport.Create(applications), + new FakeClock()); + + await handler.HandleAsync( + new OrganizationEnrollmentClaimWithdrawnIntegrationEvent( + eventId, + occurredAtUtc, + ScopeId, + OrganizationId, + application.SourceId, + claimId, + 3), + CancellationToken.None); + + Assert.Equal(expected, application.Status); + Assert.Equal(expectedVersion, application.Version); + Assert.Empty(deferred.Items); + } + + [Fact] + public async Task Unowned_claim_expiry_without_product_state_is_acknowledged() { FakeOnboardingRepository applications = new(); OrganizationEnrollmentClaimExpiredStaffOnboardingHandler handler = new( applications, new FakeAccessPlanRepository(), + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository(), WorkspaceStaffOnboardingMutationTestSupport.Create(applications), new FakeClock()); - await Assert.ThrowsAsync(() => handler.HandleAsync( + await handler.HandleAsync( new OrganizationEnrollmentClaimExpiredIntegrationEvent( Guid.NewGuid(), Now.AddMinutes(1), @@ -212,6 +403,55 @@ await Assert.ThrowsAsync(() => handler.HandleAsync( Guid.NewGuid(), Now, 1), + CancellationToken.None); + } + + [Fact] + public async Task Claim_withdrawal_with_unbound_application_and_missing_plan_is_retried() + { + WorkspaceStaffOnboarding application = WorkspaceStaffOnboardingTests.CreateApplication(); + FakeOnboardingRepository applications = new(application); + OrganizationEnrollmentClaimWithdrawnStaffOnboardingHandler handler = new( + applications, + new FakeAccessPlanRepository(), + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository(), + WorkspaceStaffOnboardingMutationTestSupport.Create(applications), + new FakeClock()); + + await Assert.ThrowsAsync(() => handler.HandleAsync( + new OrganizationEnrollmentClaimWithdrawnIntegrationEvent( + Guid.NewGuid(), + Now.AddMinutes(1), + ScopeId, + OrganizationId, + application.SourceId, + Guid.NewGuid(), + 2), + CancellationToken.None)); + } + + [Fact] + public async Task Claim_expiry_with_unbound_application_and_missing_plan_is_retried() + { + WorkspaceStaffOnboarding application = WorkspaceStaffOnboardingTests.CreateApplication(); + FakeOnboardingRepository applications = new(application); + OrganizationEnrollmentClaimExpiredStaffOnboardingHandler handler = new( + applications, + new FakeAccessPlanRepository(), + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository(), + WorkspaceStaffOnboardingMutationTestSupport.Create(applications), + new FakeClock()); + + await Assert.ThrowsAsync(() => handler.HandleAsync( + new OrganizationEnrollmentClaimExpiredIntegrationEvent( + Guid.NewGuid(), + Now.AddMinutes(1), + ScopeId, + OrganizationId, + application.SourceId, + Guid.NewGuid(), + Now, + 1), CancellationToken.None)); } @@ -227,6 +467,7 @@ public async Task Link_expiry_preserves_a_plan_while_a_pending_claim_uses_it() OrganizationEnrollmentLinkExpiredStaffOnboardingHandler handler = new( applications, new FakeAccessPlanRepository(plan), + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository(), WorkspaceStaffOnboardingMutationTestSupport.Create(applications), new FakeClock()); @@ -257,6 +498,7 @@ public async Task Link_expiry_preserves_unbound_staging_for_an_out_of_order_clai OrganizationEnrollmentLinkExpiredStaffOnboardingHandler handler = new( applications, new FakeAccessPlanRepository(plan), + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository(), WorkspaceStaffOnboardingMutationTestSupport.Create(applications), new FakeClock()); @@ -291,6 +533,7 @@ public async Task Link_expiry_terminates_a_plan_with_no_active_onboarding() OrganizationEnrollmentLinkExpiredStaffOnboardingHandler handler = new( applications, new FakeAccessPlanRepository(plan), + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository(), WorkspaceStaffOnboardingMutationTestSupport.Create(applications), new FakeClock()); diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingFlowTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingFlowTests.cs index 7db48315..c120d666 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingFlowTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingFlowTests.cs @@ -28,6 +28,52 @@ namespace BunkFy.Modules.Workspaces.Tests; [Trait("Category", "Unit")] public sealed class WorkspaceStaffOnboardingFlowTests { + [Fact] + public async Task Requested_claim_consumes_a_newer_durable_withdrawal_and_redacts_staging() + { + WorkspaceStaffOnboarding application = + WorkspaceStaffOnboardingTests.CreateApplication(); + Guid claimId = Guid.NewGuid(); + FakeRepository applications = new(application); + FakeWorkspaceStaffDeferredClaimWithdrawalRepository deferred = new( + WorkspaceStaffDeferredClaimWithdrawal.Create( + WorkspaceStaffOnboardingTests.OrganizationId.ToString("D"), + WorkspaceStaffOnboardingTests.OrganizationId, + application.SourceId, + claimId, + claimVersion: 2, + Guid.NewGuid(), + WorkspaceStaffOnboardingTests.Now.AddMinutes(2)).Value); + using ServiceProvider provider = CreateProvider( + applications, + new FakeStaffProvisioner(), + new FakeAccessControl(), + deferredWithdrawals: deferred); + OrganizationEnrollmentClaimStaffOnboardingHandler handler = provider + .GetRequiredService(); + + await handler.HandleAsync( + new OrganizationEnrollmentClaimChangedIntegrationEvent( + Guid.NewGuid(), + WorkspaceStaffOnboardingTests.Now.AddMinutes(1), + WorkspaceStaffOnboardingTests.OrganizationId.ToString("D"), + WorkspaceStaffOnboardingTests.OrganizationId, + application.SourceId, + claimId, + application.SubjectId, + OrganizationEnrollmentClaimChange.Requested, + OrganizationEnrollmentClaimStatus.Pending, + null, + 1), + CancellationToken.None); + + Assert.Equal(WorkspaceStaffOnboardingState.Withdrawn, application.Status); + Assert.Equal(2, application.ClaimVersion); + Assert.Null(application.VerifiedAccountEmail); + Assert.Null(application.DisplayName); + Assert.Empty(deferred.Items); + } + [Fact] public async Task Accepted_claim_event_records_its_version_before_provisioning() { @@ -212,6 +258,49 @@ public async Task Access_failure_retries_without_duplicate_staff_and_then_redact Assert.Null(retried.Value.DisplayName); } + [Fact] + public async Task Restriction_release_recovery_completes_the_last_application_and_finalizes_its_expired_source() + { + WorkspaceStaffOnboarding application = + WorkspaceStaffOnboardingTests.CreateApplication(); + Assert.True(application.ObserveClaimAccepted( + Guid.NewGuid(), + 1, + WorkspaceStaffOnboardingTests.Now.AddMinutes(1)).IsSuccess); + Assert.True(application.BeginProvisioning( + WorkspaceStaffOnboardingTests.Now.AddMinutes(2)).IsSuccess); + FakeRepository applications = new(application); + using ServiceProvider provider = CreateProvider( + applications, + new FakeStaffProvisioner(), + new FakeAccessControl()); + WorkspaceStaffAccessPlan plan = (await provider + .GetRequiredService() + .GetAsync(application.SourceId, CancellationToken.None))!; + Assert.True(plan.ObserveSourceExpired( + WorkspaceStaffOnboardingTests.Now.AddMinutes(2), + WorkspaceStaffOnboardingTests.Now.AddMinutes(2)).IsSuccess); + WorkspaceStaffOnboardingProcessingRestrictionRecoveryHandler handler = + provider.GetRequiredService< + WorkspaceStaffOnboardingProcessingRestrictionRecoveryHandler>(); + + await handler.HandleAsync( + new WorkspaceStaffOnboardingProcessingRestrictionChangedIntegrationEvent( + Guid.NewGuid(), + application.ScopeId, + WorkspaceStaffOnboardingTests.Now.AddMinutes(3), + application.Id, + WorkspaceStaffOnboardingProcessingRestrictionContract + .CurrentVersion, + projectionRevision: 1, + isRestricted: false), + CancellationToken.None); + + Assert.Equal(WorkspaceStaffOnboardingState.Completed, application.Status); + Assert.Equal(WorkspaceStaffAccessPlanState.Expired, plan.Status); + Assert.Null(application.DisplayName); + } + private static void AssertProvisionerAssignment( (AccessSubject Subject, string RoleName, AccessScope Scope) call) { @@ -260,6 +349,163 @@ public async Task Submission_derives_workspace_authority_from_the_token_and_veri provider.GetRequiredService().ScopeId); } + [Theory] + [InlineData(OrganizationEnrollmentClaimStatus.Unknown)] + [InlineData(OrganizationEnrollmentClaimStatus.Accepted)] + [InlineData(OrganizationEnrollmentClaimStatus.Rejected)] + [InlineData(OrganizationEnrollmentClaimStatus.Expired)] + [InlineData(OrganizationEnrollmentClaimStatus.Withdrawn)] + public async Task Terminal_or_unknown_enrollment_claim_fences_resubmission( + OrganizationEnrollmentClaimStatus status) + { + WorkspaceStaffOnboarding application = CreateApplication( + Guid.NewGuid(), + WorkspaceStaffOnboardingSource.EnrollmentLink, + Guid.NewGuid(), + "Profile A"); + FakeOrganizationEnrollmentClaimInspector claims = new( + Claim(application, Guid.NewGuid(), status)); + using ServiceProvider provider = CreateProvider( + new FakeRepository(application), + new FakeStaffProvisioner(), + new FakeAccessControl(), + new FakeJoinTokenInspector( + WorkspaceStaffOnboardingTests.OrganizationId, + application.SourceId), + claims: claims); + + Result result = await SubmitAsync( + provider, + WorkspaceStaffOnboardingSourceKind.EnrollmentLink, + application, + "Profile B"); + + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors + .ProfileMutationAuthorityUnavailable, + result.Error); + Assert.Equal("Profile A", application.DisplayName); + Assert.Equal(1, application.Version); + Assert.Single(claims.Requests); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Missing_or_exact_pending_enrollment_claim_allows_resubmission( + bool hasPendingClaim) + { + WorkspaceStaffOnboarding application = CreateApplication( + Guid.NewGuid(), + WorkspaceStaffOnboardingSource.EnrollmentLink, + Guid.NewGuid(), + "Profile A"); + FakeOrganizationEnrollmentClaimInspector claims = new( + hasPendingClaim + ? Claim( + application, + Guid.NewGuid(), + OrganizationEnrollmentClaimStatus.Pending) + : null); + using ServiceProvider provider = CreateProvider( + new FakeRepository(application), + new FakeStaffProvisioner(), + new FakeAccessControl(), + new FakeJoinTokenInspector( + WorkspaceStaffOnboardingTests.OrganizationId, + application.SourceId), + claims: claims); + + Result result = await SubmitAsync( + provider, + WorkspaceStaffOnboardingSourceKind.EnrollmentLink, + application, + "Profile B"); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal("Profile B", application.DisplayName); + Assert.Equal(2, application.Version); + Assert.Single(claims.Requests); + } + + [Theory] + [InlineData("organization")] + [InlineData("source")] + [InlineData("subject")] + public async Task Mismatched_pending_enrollment_claim_fences_resubmission( + string coordinate) + { + WorkspaceStaffOnboarding application = CreateApplication( + Guid.NewGuid(), + WorkspaceStaffOnboardingSource.EnrollmentLink, + Guid.NewGuid(), + "Profile A"); + OrganizationEnrollmentClaimDto claim = Claim( + application, + Guid.NewGuid(), + OrganizationEnrollmentClaimStatus.Pending); + claim = coordinate switch + { + "organization" => claim with { OrganizationId = Guid.NewGuid() }, + "source" => claim with { EnrollmentLinkId = Guid.NewGuid() }, + _ => claim with { SubjectId = Guid.NewGuid().ToString("D") } + }; + FakeOrganizationEnrollmentClaimInspector claims = new(claim); + using ServiceProvider provider = CreateProvider( + new FakeRepository(application), + new FakeStaffProvisioner(), + new FakeAccessControl(), + new FakeJoinTokenInspector( + WorkspaceStaffOnboardingTests.OrganizationId, + application.SourceId), + claims: claims); + + Result result = await SubmitAsync( + provider, + WorkspaceStaffOnboardingSourceKind.EnrollmentLink, + application, + "Profile B"); + + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors + .ProfileMutationAuthorityUnavailable, + result.Error); + Assert.Equal("Profile A", application.DisplayName); + } + + [Fact] + public async Task Invitation_resubmission_does_not_query_enrollment_claims() + { + WorkspaceStaffOnboarding application = CreateApplication( + Guid.NewGuid(), + WorkspaceStaffOnboardingSource.Invitation, + Guid.NewGuid(), + "Profile A"); + FakeOrganizationEnrollmentClaimInspector claims = new( + Claim( + application, + Guid.NewGuid(), + OrganizationEnrollmentClaimStatus.Accepted)); + using ServiceProvider provider = CreateProvider( + new FakeRepository(application), + new FakeStaffProvisioner(), + new FakeAccessControl(), + new FakeJoinTokenInspector( + WorkspaceStaffOnboardingTests.OrganizationId, + application.SourceId), + claims: claims); + + Result result = await SubmitAsync( + provider, + WorkspaceStaffOnboardingSourceKind.Invitation, + application, + "Profile B"); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal("Profile B", application.DisplayName); + Assert.Empty(claims.Requests); + } + [Fact] public async Task Submission_rejects_a_member_without_current_auth_admission() { @@ -618,13 +864,79 @@ public async Task Admission_reports_unavailable_when_workspace_state_is_not_auth Assert.Equal(OrganizationJoinAdmissionDecision.Unavailable, decision); } + private static WorkspaceStaffOnboarding CreateApplication( + Guid applicationId, + WorkspaceStaffOnboardingSource sourceKind, + Guid sourceId, + string displayName) => + WorkspaceStaffOnboarding.Create( + applicationId, + WorkspaceStaffOnboardingTests.OrganizationId.ToString("D"), + sourceKind, + sourceId, + WorkspaceStaffOnboardingTests.SubjectId, + "verified@example.test", + displayName, + "Ada Lovelace", + "ada@workspace.test", + "+1 555 0100", + "EMP-100", + "Manager", + "Operations", + WorkspaceStaffOnboardingTests.Now).Value; + + private static OrganizationEnrollmentClaimDto Claim( + WorkspaceStaffOnboarding application, + Guid claimId, + OrganizationEnrollmentClaimStatus status) => new( + claimId, + application.SourceId, + WorkspaceStaffOnboardingTests.OrganizationId, + application.SubjectId, + status, + status == OrganizationEnrollmentClaimStatus.Accepted + ? Guid.NewGuid() + : null, + Version: 2, + WorkspaceStaffOnboardingTests.Now, + WorkspaceStaffOnboardingTests.Now.AddMinutes(1)) + { + DecisionExpiresAtUtc = + status == OrganizationEnrollmentClaimStatus.Pending + ? WorkspaceStaffOnboardingTests.Now.AddMinutes(5) + : null + }; + + private static Task> SubmitAsync( + ServiceProvider provider, + WorkspaceStaffOnboardingSourceKind sourceKind, + WorkspaceStaffOnboarding application, + string displayName) => + provider.GetRequiredService() + .SubmitAsync( + new SubmitWorkspaceStaffOnboardingCommand( + sourceKind, + "secret-token", + application.SubjectId, + displayName, + application.LegalName, + application.WorkEmail, + application.WorkPhone, + application.EmployeeNumber, + application.JobTitle, + application.Department), + CancellationToken.None); + private static ServiceProvider CreateProvider( FakeRepository applications, FakeStaffProvisioner staff, FakeAccessControl access, FakeJoinTokenInspector? tokens = null, FakeAdmissionReader? admissions = null, - WorkspaceTerminationFenceSnapshot? terminationFence = null) + WorkspaceTerminationFenceSnapshot? terminationFence = null, + FakeWorkspaceStaffDeferredClaimWithdrawalRepository? + deferredWithdrawals = null, + FakeOrganizationEnrollmentClaimInspector? claims = null) { HostApplicationBuilder builder = new(new HostApplicationBuilderSettings { @@ -657,6 +969,9 @@ private static ServiceProvider CreateProvider( services.AddSingleton( new FakeOperationLock()); services.AddSingleton(new FakeAccessPlanRepository(plans)); + services.AddSingleton( + deferredWithdrawals ?? + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository()); services.AddSingleton(staff); services.AddSingleton(new FakeStaffPropertyProvisioner()); services.AddSingleton(access); @@ -665,6 +980,8 @@ private static ServiceProvider CreateProvider( services.AddSingleton(new AllowAllAuthorizationService()); services.AddSingleton(new FakePropertyProjectionRepository()); services.AddSingleton(tokenInspector); + services.AddSingleton( + claims ?? new FakeOrganizationEnrollmentClaimInspector()); services.AddSingleton( admissions ?? new FakeAdmissionReader()); services.AddSingleton( diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingMutationCoordinatorTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingMutationCoordinatorTests.cs index 5f50340a..d3b2dacc 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingMutationCoordinatorTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingMutationCoordinatorTests.cs @@ -6,6 +6,7 @@ namespace BunkFy.Modules.Workspaces.Tests; using BunkFy.Modules.Workspaces.Contracts; using BunkFy.Modules.Workspaces.Domain; using Gma.Framework.Pagination; +using Gma.Modules.Organizations.Contracts; using Xunit; [Trait("Category", "Unit")] @@ -175,6 +176,17 @@ public void Direct_source_graph_writers_require_mutation_coordinator( typeof(WorkspaceStaffOnboardingMutationCoordinator)); } + [Theory] + [InlineData(typeof(SubmitWorkspaceStaffOnboardingCommandHandler))] + [InlineData(typeof(ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler))] + public void Enrollment_profile_mutations_require_organizations_claim_authority( + Type writerType) + { + AssertDependency( + writerType, + typeof(IOrganizationEnrollmentClaimInspector)); + } + [Theory] [InlineData(typeof(RetryWorkspaceStaffOnboardingCommandHandler))] [InlineData(typeof(WorkspaceStaffOnboardingProcessingRestrictionRecoveryHandler))] diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingMutationTestSupport.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingMutationTestSupport.cs index d3a6b439..cbb6707a 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingMutationTestSupport.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingMutationTestSupport.cs @@ -2,6 +2,8 @@ namespace BunkFy.Modules.Workspaces.Tests; using BunkFy.Modules.Workspaces.Application.Handlers; using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Modules.Organizations.Contracts; internal static class WorkspaceStaffOnboardingMutationTestSupport { @@ -34,3 +36,68 @@ public Task TryAcquireAsync( CancellationToken cancellationToken) => Task.FromResult(true); } } + +internal sealed class FakeOrganizationEnrollmentClaimInspector( + OrganizationEnrollmentClaimDto? claim = null) + : IOrganizationEnrollmentClaimInspector +{ + public OrganizationEnrollmentClaimDto? Claim { get; set; } = claim; + + public List<(Guid OrganizationId, Guid EnrollmentLinkId, string SubjectId)> + Requests + { get; } = []; + + public Task FindAsync( + Guid organizationId, + Guid enrollmentLinkId, + string subjectId, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + this.Requests.Add((organizationId, enrollmentLinkId, subjectId)); + return Task.FromResult(this.Claim); + } +} + +internal sealed class FakeWorkspaceStaffDeferredClaimWithdrawalRepository( + params WorkspaceStaffDeferredClaimWithdrawal[] seed) + : IWorkspaceStaffDeferredClaimWithdrawalRepository +{ + private readonly List withdrawals = + [.. seed]; + + public IReadOnlyList Items => + this.withdrawals; + + public Task GetAsync( + Guid claimId, + CancellationToken cancellationToken) => + Task.FromResult(this.withdrawals.SingleOrDefault(item => + item.Id == claimId)); + + public Task AnyBySourceAsync( + Guid enrollmentLinkId, + CancellationToken cancellationToken) => + Task.FromResult(this.withdrawals.Any(item => + item.EnrollmentLinkId == enrollmentLinkId)); + + public Task AddAsync( + WorkspaceStaffDeferredClaimWithdrawal withdrawal, + CancellationToken cancellationToken) + { + this.withdrawals.Add(withdrawal); + return Task.CompletedTask; + } + + public void Remove(WorkspaceStaffDeferredClaimWithdrawal withdrawal) => + this.withdrawals.Remove(withdrawal); + + public Task RemoveBySourceAsync( + Guid enrollmentLinkId, + CancellationToken cancellationToken) + { + int removed = this.withdrawals.RemoveAll(item => + item.EnrollmentLinkId == enrollmentLinkId); + return Task.FromResult(removed); + } +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingProcessingRestrictionEnforcementTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingProcessingRestrictionEnforcementTests.cs index a23ac18f..35930b30 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingProcessingRestrictionEnforcementTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingProcessingRestrictionEnforcementTests.cs @@ -215,7 +215,10 @@ public async Task Release_recovery_rechecks_projection_and_stops_reapply_race() WorkspaceStaffOnboardingProcessingRestrictionRecoveryHandler handler = new( applications, + new RecordingPlanRepository(), + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository(), processor, + new TestClock(), NullLogger< WorkspaceStaffOnboardingProcessingRestrictionRecoveryHandler> .Instance); @@ -244,7 +247,7 @@ await handler.HandleAsync( Assert.Equal(1, applications.GetCount); Assert.Equal( - ["get", "source", "lock", "reload", "projection"], + ["get", "source-write", "lock", "reload", "projection"], calls); Assert.Equal(1, operationLock.CallCount); Assert.Equal(0, staff.CallCount); @@ -279,7 +282,10 @@ public async Task Release_recovery_failure_is_surfaced_for_inbox_retry() WorkspaceStaffOnboardingProcessingRestrictionRecoveryHandler handler = new( applications, + new RecordingPlanRepository(), + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository(), processor, + new TestClock(), NullLogger< WorkspaceStaffOnboardingProcessingRestrictionRecoveryHandler> .Instance); @@ -325,6 +331,7 @@ public async Task Resubmission_of_restricted_onboarding_never_contacts_auth() new WorkspaceStaffJoinTokenAuthorityResolver( new EnrollmentTokenInspector(OrganizationId, sourceId)), admissions, + new FakeOrganizationEnrollmentClaimInspector(), Options.Create(new WorkspaceStaffOnboardingOptions { GlobalAuthScopeId = "global" diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingRetentionTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingRetentionTests.cs index 876f7262..07945648 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingRetentionTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingRetentionTests.cs @@ -243,6 +243,61 @@ await handler.HandleAsync( Assert.Null(application.DisplayName); } + [Fact] + public async Task Deferred_withdrawal_and_authoritative_expiry_fail_closed_and_retain_the_fact() + { + WorkspaceStaffOnboarding application = + WorkspaceStaffOnboardingTests.CreateApplication(); + Guid claimId = Guid.NewGuid(); + Assert.True(application.ObserveClaimRequested( + claimId, + 1, + Now.AddHours(-4)).IsSuccess); + WorkspaceStaffAccessPlan plan = CreatePlan( + application, + Now.AddHours(-3), + active: true); + OrganizationEnrollmentClaimDto authoritative = new( + claimId, + application.SourceId, + WorkspaceStaffOnboardingTests.OrganizationId, + application.SubjectId, + OrganizationEnrollmentClaimStatus.Expired, + MembershipId: null, + Version: 2, + CreatedAtUtc: Now.AddHours(-4), + LastChangedAtUtc: Now.AddHours(-2)); + WorkspaceStaffDeferredClaimWithdrawal withdrawal = + WorkspaceStaffDeferredClaimWithdrawal.Create( + application.ScopeId, + WorkspaceStaffOnboardingTests.OrganizationId, + application.SourceId, + claimId, + 2, + Guid.NewGuid(), + Now.AddHours(-2)).Value; + FakeWorkspaceStaffDeferredClaimWithdrawalRepository deferred = new(withdrawal); + ReconcileWorkspaceStaffOnboardingRetentionCandidateCommandHandler handler = + CreateHandler( + new FakeOnboardingRepository(application), + new FakeAccessPlanRepository(plan), + new FakeClaimInspector(authoritative), + deferred); + + Result result = + await handler.HandleAsync( + new(application.Id, application.Version), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors.RetentionClaimInconsistent, + result.Error); + Assert.Equal(WorkspaceStaffOnboardingState.PendingApproval, application.Status); + Assert.NotNull(application.DisplayName); + Assert.Same(withdrawal, Assert.Single(deferred.Items)); + } + [Fact] public async Task Accepted_claim_enters_existing_recoverable_processing_path() { @@ -276,6 +331,7 @@ public async Task Accepted_claim_enters_existing_recoverable_processing_path() ReconcileWorkspaceStaffOnboardingRetentionCandidateCommandHandler handler = new( applications, plans, + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository(), WorkspaceStaffOnboardingMutationTestSupport.Create( applications, new FakeOperationLock()), @@ -333,10 +389,12 @@ private static ReconcileWorkspaceStaffOnboardingRetentionCandidateCommandHandler CreateHandler( FakeOnboardingRepository applications, FakeAccessPlanRepository plans, - FakeClaimInspector inspector) => + FakeClaimInspector inspector, + FakeWorkspaceStaffDeferredClaimWithdrawalRepository? deferred = null) => new( applications, plans, + deferred ?? new FakeWorkspaceStaffDeferredClaimWithdrawalRepository(), WorkspaceStaffOnboardingMutationTestSupport.Create( applications, new FakeOperationLock()), diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceTenantTerminationContributorTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceTenantTerminationContributorTests.cs index a5b924a1..cf5db3e6 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceTenantTerminationContributorTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceTenantTerminationContributorTests.cs @@ -23,6 +23,21 @@ public sealed class WorkspaceTenantTerminationContributorTests private static readonly DateTimeOffset Now = new(2026, 7, 31, 7, 0, 0, TimeSpan.Zero); + [Fact] + public void Catalog_v12_has_one_immutable_manifest_identity() + { + Assert.Equal(12, WorkspacesTenantTerminationMetadata.CatalogVersion); + Assert.Equal( + 12, + WorkspacesTenantTerminationMetadata.PersonalDataCatalogVersion); + Assert.Equal( + "fc26fc9027bff3e5c859a261ac589ff7a576da29a9c3ea10ad30886789a74022", + WorkspacesTenantTerminationMetadata.CatalogSha256); + Assert.NotEqual( + "29475cb08300f9bbee923231b2059dc0de55b64875bfb21d32be64c18728b301", + WorkspacesTenantTerminationMetadata.CatalogSha256); + } + [Fact] public async Task Freeze_returns_exact_pii_free_owner_proof() { diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Contracts/WorkspacesPersonalDataCatalogTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Contracts/WorkspacesPersonalDataCatalogTests.cs index a0921a7a..89c2d7bb 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Contracts/WorkspacesPersonalDataCatalogTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Contracts/WorkspacesPersonalDataCatalogTests.cs @@ -155,6 +155,7 @@ private static IEnumerable Bindings() => private static Type[] PersistenceTypes() => [ typeof(WorkspaceStaffOnboarding), + typeof(WorkspaceStaffDeferredClaimWithdrawal), typeof(WorkspaceStaffOnboardingCorrectionReceipt), typeof(WorkspaceStaffOnboardingProcessingRestriction), typeof(WorkspaceStaffOnboardingProcessingRestrictionProjection), @@ -280,6 +281,8 @@ private static Type[] PersistenceTypes() => foreach (Type type in new[] { typeof(WorkspaceStaffOnboardingDataRightsExport), + typeof( + WorkspaceStaffDeferredClaimWithdrawalDataRightsExport), typeof(WorkspaceStaffAccessProcessDataRightsExport), typeof(WorkspaceStaffAccessProfileDataRightsExport), typeof(WorkspaceStaffAccessPlanDataRightsExport), diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Domain/WorkspaceStaffOnboardingTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Domain/WorkspaceStaffOnboardingTests.cs index 429810b1..22552017 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Domain/WorkspaceStaffOnboardingTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Domain/WorkspaceStaffOnboardingTests.cs @@ -7,6 +7,35 @@ namespace BunkFy.Modules.Workspaces.Tests; [Trait("Category", "Unit")] public sealed class WorkspaceStaffOnboardingTests { + [Fact] + public void Deferred_withdrawal_canonicalizes_equivalent_organization_scope_text() + { + Guid claimId = Guid.NewGuid(); + Guid enrollmentLinkId = Guid.NewGuid(); + Guid eventId = Guid.NewGuid(); + string equivalentScope = OrganizationId.ToString("N").ToUpperInvariant(); + + WorkspaceStaffDeferredClaimWithdrawal withdrawal = + WorkspaceStaffDeferredClaimWithdrawal.Create( + equivalentScope, + OrganizationId, + enrollmentLinkId, + claimId, + 2, + eventId, + Now).Value; + + Assert.Equal(OrganizationId.ToString("D"), withdrawal.ScopeId); + Assert.True(withdrawal.Matches( + equivalentScope, + OrganizationId, + enrollmentLinkId, + claimId, + 2, + eventId, + Now)); + } + [Fact] public void Completion_requires_staff_and_redacts_applicant_data() { diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesTenantTerminationExportContributorTests.Destroy.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesTenantTerminationExportContributorTests.Destroy.cs index a563bde2..bf9fa89e 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesTenantTerminationExportContributorTests.Destroy.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesTenantTerminationExportContributorTests.Destroy.cs @@ -31,7 +31,7 @@ public async Task Destroy_resumes_to_completion_and_exactly_replays() TenantTerminationContributionStatus.Completed, result.Status); Assert.Equal("workspace.termination.destroyed", result.ResultCode); - Assert.Equal(10, result.AffectedCount); + Assert.Equal(11, result.AffectedCount); Assert.Equal(1, result.SelectedProofRevision); Assert.Equal(3, result.ResultingProofRevision); Assert.Empty(await context.TenantDestroyOperations.ToListAsync()); @@ -162,6 +162,8 @@ await context.StaffAccessProcesses await context.StaffAccessProcesses.IgnoreQueryFilters().AnyAsync() || await context.StaffOnboardingApplications .IgnoreQueryFilters().AnyAsync() || + await context.StaffDeferredClaimWithdrawals + .IgnoreQueryFilters().AnyAsync() || await context.StaffRetentionCorrelationReceipts .IgnoreQueryFilters().AnyAsync() || await context.StaffCorrelationAnonymisationRestoreReceipts diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesTenantTerminationExportContributorTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesTenantTerminationExportContributorTests.cs index e6dc92ce..e36312fd 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesTenantTerminationExportContributorTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesTenantTerminationExportContributorTests.cs @@ -69,6 +69,8 @@ await contributor.ExportAsync( Assert.Equal( [ WorkspacesDataRightsCoordinates.StaffOnboardingRecordType, + WorkspacesDataRightsExportContributor + .StaffDeferredClaimWithdrawalRecordType, WorkspacesDataRightsExportContributor .StaffOnboardingCorrectionReceiptRecordType, WorkspacesDataRightsExportContributor @@ -85,7 +87,25 @@ await contributor.ExportAsync( .StaffRetentionCorrelationReceiptRecordType ], first.Records.Select(record => record.RecordType).ToArray()); - Assert.Equal(9, result.AffectedCount); + Assert.Equal(10, result.AffectedCount); + DataRightsExportRecord deferredRecord = first.Records[1]; + Assert.Equal( + Guid.Parse("61000000-0000-0000-0000-000000000001"), + deferredRecord.RecordId); + Assert.Equal(2, deferredRecord.RecordVersion); + Assert.Equal( + [ + "workspaces.enrollment-claim-id", + "workspaces.enrollment-claim-version", + "workspaces.integration-event-id", + "workspaces.integration-event-occurred-at", + "workspaces.join-source-id", + "workspaces.workspace-scope-id" + ], + deferredRecord.Fields + .Select(field => field.FieldId) + .Order(StringComparer.Ordinal) + .ToArray()); Assert.Equal( WorkspacesTenantTerminationMetadata.ExportSchemaId, contributor.ExportDescriptor.ExportSchemaId); @@ -293,9 +313,19 @@ private static void SeedGraph(WorkspacesDbContext context) accessProcessRecordsScrubbed: 0, accessPlanRecordsScrubbed: 0, FrozenAtUtc.AddHours(-6)).Value; + WorkspaceStaffDeferredClaimWithdrawal deferred = + WorkspaceStaffDeferredClaimWithdrawal.Create( + TenantId, + Guid.Parse(TenantId), + Guid.Parse("51000000-0000-0000-0000-000000000001"), + Guid.Parse("61000000-0000-0000-0000-000000000001"), + claimVersion: 2, + Guid.Parse("71000000-0000-0000-0000-000000000001"), + FrozenAtUtc.AddHours(-5)).Value; context.AddRange( onboarding, + deferred, correction, restriction, restrictionReceipt, diff --git a/tests/Integration.Tests/AdminApi/AdminApiIntegrationTests.cs b/tests/Integration.Tests/AdminApi/AdminApiIntegrationTests.cs index 35fa6668..77766bde 100644 --- a/tests/Integration.Tests/AdminApi/AdminApiIntegrationTests.cs +++ b/tests/Integration.Tests/AdminApi/AdminApiIntegrationTests.cs @@ -23,6 +23,74 @@ namespace Integration.Tests; public sealed class AdminApiIntegrationTests { + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Global_auth_session_uses_rbac_for_a_non_default_tenant_until_revoked() + { + await using IContainer nats = AuthTestContainers.CreateNatsContainer(); + await using PostgreSqlContainer postgreSql = new PostgreSqlBuilder( + "postgres:16-alpine") + .WithDatabase("bunkfy_admin_global_auth_session_tests") + .Build(); + await nats.StartAsync(); + await postgreSql.StartAsync(); + + await using AdminApiTestApplication application = new( + "PostgreSql", + postgreSql.GetConnectionString(), + AuthTestContainers.GetNatsConnectionString(nats), + useActiveSessionAdmission: true); + await application.MigrateAsync().ConfigureAwait(false); + + Guid nonOwnerId = Guid.NewGuid(); + string nonOwnerToken = await application + .CreatePersistedGlobalAccessTokenAsync(nonOwnerId) + .ConfigureAwait(false); + using HttpClient nonOwnerClient = application.CreateClient(); + nonOwnerClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", nonOwnerToken); + nonOwnerClient.DefaultRequestHeaders.Add( + "X-Tenant-Id", + "tenant-not-auth-scope"); + using HttpResponseMessage forbidden = await nonOwnerClient + .GetAsync("/api/admin/workspaces/access-bootstrap") + .ConfigureAwait(false); + Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode); + + Guid ownerId = Guid.NewGuid(); + string ownerToken = await application + .CreatePersistedGlobalAccessTokenAsync(ownerId) + .ConfigureAwait(false); + await application.SeedOwnerAsync(ownerId).ConfigureAwait(false); + + using HttpClient ownerClient = application.CreateClient(); + ownerClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", ownerToken); + ownerClient.DefaultRequestHeaders.Add( + "X-Tenant-Id", + "tenant-not-auth-scope"); + using HttpResponseMessage admitted = await ownerClient + .GetAsync("/api/admin/workspaces/access-bootstrap") + .ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, admitted.StatusCode); + + using HttpResponseMessage revoke = await ownerClient.PostAsJsonAsync( + $"/api/admin/auth/members/{ownerId:D}/revoke-sessions", + new { confirmed = true }).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, revoke.StatusCode); + AdminRevokeSessionsResponse? revokeResult = await revoke.Content + .ReadFromJsonAsync() + .ConfigureAwait(false); + Assert.NotNull(revokeResult); + Assert.Equal(1, revokeResult.RevokedSessionCount); + + using HttpResponseMessage deniedAfterRevocation = await ownerClient + .GetAsync("/api/admin/workspaces/access-bootstrap") + .ConfigureAwait(false); + Assert.Equal(HttpStatusCode.Unauthorized, deniedAfterRevocation.StatusCode); + } + [DockerFact] [Trait("Category", "Docker")] [Trait("Category", "Integration")] diff --git a/tests/Integration.Tests/Support/AdminApiTestApplication.cs b/tests/Integration.Tests/Support/AdminApiTestApplication.cs index 0829b803..0ee3413a 100644 --- a/tests/Integration.Tests/Support/AdminApiTestApplication.cs +++ b/tests/Integration.Tests/Support/AdminApiTestApplication.cs @@ -11,9 +11,12 @@ namespace Integration.Tests.Support; using Gma.Modules.AccessControl.Persistence; using Gma.Modules.Administration.Persistence; using Gma.Modules.Administration.Persistence.Entities; +using Gma.Modules.Auth.Application.Ports; +using Gma.Modules.Auth.Domain.Aggregates; +using Gma.Modules.Auth.Domain.Enums; +using Gma.Modules.Auth.Domain.Repositories; using Gma.Modules.Auth.Domain.Services; using Gma.Modules.Auth.Domain.ValueObjects; -using Gma.Modules.Auth.Application.Ports; using Gma.Modules.Auth.Persistence; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -30,7 +33,8 @@ internal sealed class AdminApiTestApplication( string providerConnectionString, string natsConnectionString, bool disableOutboxPublisher = true, - bool allowGeneratedPasswordResponses = false) + bool allowGeneratedPasswordResponses = false, + bool useActiveSessionAdmission = false) : WebApplicationFactory { private const string JwtIssuer = "BunkFy"; @@ -54,7 +58,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.UseSetting("Auth:Jwt:Audience", JwtAudience); builder.UseSetting("Auth:Jwt:SigningKey", JwtSigningKey); builder.UseSetting("Auth:Jwt:AccessTokenLifetimeMinutes", "15"); - builder.UseSetting("Auth:BearerAdmission:Mode", "TokenLifetime"); + builder.UseSetting( + "Auth:BearerAdmission:Mode", + useActiveSessionAdmission ? "ActiveSession" : "TokenLifetime"); builder.UseSetting( "Administration:Api:AllowGeneratedPasswordResponses", allowGeneratedPasswordResponses.ToString(System.Globalization.CultureInfo.InvariantCulture)); @@ -91,7 +97,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ["Auth:Jwt:Audience"] = JwtAudience, ["Auth:Jwt:SigningKey"] = JwtSigningKey, ["Auth:Jwt:AccessTokenLifetimeMinutes"] = "15", - ["Auth:BearerAdmission:Mode"] = "TokenLifetime", + ["Auth:BearerAdmission:Mode"] = useActiveSessionAdmission + ? "ActiveSession" + : "TokenLifetime", ["Administration:Api:AllowGeneratedPasswordResponses"] = allowGeneratedPasswordResponses.ToString(System.Globalization.CultureInfo.InvariantCulture), ["Caching:Enabled"] = "false", ["FileManagement:Enabled"] = "true", @@ -186,6 +194,67 @@ public async Task SeedOwnerAsync(Guid actorId) } } + public async Task CreatePersistedGlobalAccessTokenAsync(Guid actorId) + { + const string globalScopeId = "default"; + DateTimeOffset nowUtc = DateTimeOffset.UtcNow; + SessionAuthenticationEvidence authenticationEvidence = + SessionAuthenticationEvidence.CompleteWithTotp( + SessionAuthenticationEvidence.Password(nowUtc), + nowUtc); + + using IServiceScope scope = this.Services.CreateScope(); + IPasswordHashingService passwordHashingService = + scope.ServiceProvider.GetRequiredService(); + IRefreshTokenHashingService refreshTokenHashingService = + scope.ServiceProvider.GetRequiredService(); + IMemberRepository memberRepository = + scope.ServiceProvider.GetRequiredService(); + AuthDbContext dbContext = + scope.ServiceProvider.GetRequiredService(); + ITokenService tokenService = + scope.ServiceProvider.GetRequiredService(); + + var memberResult = Member.Create( + new MemberId(actorId), + globalScopeId, + $"admin-{actorId:N}@example.com", + MemberUsernameType.Email, + passwordHashingService.HashPassword("Passw0rd!admin-session"), + new MemberUsernameId(Guid.NewGuid()), + Guid.NewGuid(), + nowUtc); + if (memberResult.IsFailure) + { + throw new InvalidOperationException(memberResult.Error.Message); + } + + Member member = memberResult.Value; + MemberSessionId sessionId = new(Guid.NewGuid()); + var sessionResult = member.StartSession( + sessionId, + refreshTokenHashingService.HashRefreshToken(Guid.NewGuid().ToString("N")), + nowUtc.AddDays(30), + nowUtc, + authenticationEvidence: authenticationEvidence, + maximumActiveSessions: 1, + absoluteExpiresAtUtc: nowUtc.AddDays(90)); + if (sessionResult.IsFailure) + { + throw new InvalidOperationException(sessionResult.Error.Message); + } + + await memberRepository.AddAsync(member, CancellationToken.None) + .ConfigureAwait(false); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + + return tokenService.GenerateAccessToken(new AccessTokenClaims( + member.Id, + globalScopeId, + sessionId, + authenticationEvidence)); + } + public async Task CountAuditEntriesAsync(string operation, string? errorCode = null) { IConfiguration configuration = this.CreatePersistenceConfiguration(); diff --git a/tests/Integration.Tests/Workspaces/WorkspaceStaffOnboardingExpiryPersistenceTests.cs b/tests/Integration.Tests/Workspaces/WorkspaceStaffOnboardingExpiryPersistenceTests.cs index c14cf4f1..e237e865 100644 --- a/tests/Integration.Tests/Workspaces/WorkspaceStaffOnboardingExpiryPersistenceTests.cs +++ b/tests/Integration.Tests/Workspaces/WorkspaceStaffOnboardingExpiryPersistenceTests.cs @@ -21,6 +21,267 @@ namespace Integration.Tests; public sealed class WorkspaceStaffOnboardingExpiryPersistenceTests { + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Withdrawal_before_requested_survives_redelivery_and_converges_under_the_source_lock() + { + await using PostgreSqlContainer postgreSql = new PostgreSqlBuilder( + "postgres:16-alpine") + .WithDatabase("bunkfy_workspace_withdrawal_order_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + using IHost worker = CreateWorker(postgreSql.GetConnectionString()); + Guid organizationId = Guid.NewGuid(); + string scopeId = organizationId.ToString("D"); + Guid linkId = Guid.NewGuid(); + Guid claimId = Guid.NewGuid(); + DateTimeOffset nowUtc = new( + 2026, + 8, + 11, + 8, + 0, + 0, + TimeSpan.Zero); + string subjectId = Guid.NewGuid().ToString("D"); + + using (IServiceScope seedScope = worker.Services.CreateScope()) + { + seedScope.ServiceProvider.GetRequiredService() + .SetTenant(scopeId); + WorkspacesDbContext seed = seedScope.ServiceProvider + .GetRequiredService(); + await seed.Database.MigrateAsync().ConfigureAwait(false); + (WorkspaceStaffOnboarding application, WorkspaceStaffAccessPlan plan) = + CreateUnboundApplicationAndPlan( + scopeId, + linkId, + subjectId, + nowUtc, + "ordered"); + seed.StaffOnboardingApplications.Add(application); + seed.StaffAccessPlans.Add(plan); + await seed.SaveChangesAsync().ConfigureAwait(false); + } + + OrganizationEnrollmentClaimWithdrawnIntegrationEvent withdrawal = new( + Guid.NewGuid(), + nowUtc.AddMinutes(3).AddTicks(1), + scopeId, + organizationId, + linkId, + claimId, + 2); + for (int delivery = 0; delivery < 6; delivery++) + { + using IServiceScope deliveryScope = worker.Services.CreateScope(); + deliveryScope.ServiceProvider + .GetRequiredService() + .SetTenant(scopeId); + WorkspacesDbContext deliveryDb = deliveryScope.ServiceProvider + .GetRequiredService(); + IIntegrationEventHandler< + OrganizationEnrollmentClaimWithdrawnIntegrationEvent> handler = + GetHandler( + deliveryScope.ServiceProvider, + WorkspacesModuleMetadata.EnrollmentClaimWithdrawnHandlerName); + await HandleInTransactionAsync( + deliveryDb, + () => handler.HandleAsync( + withdrawal, + CancellationToken.None)).ConfigureAwait(false); + } + + using (IServiceScope verificationScope = worker.Services.CreateScope()) + { + verificationScope.ServiceProvider + .GetRequiredService() + .SetTenant(scopeId); + WorkspacesDbContext verification = verificationScope.ServiceProvider + .GetRequiredService(); + WorkspaceStaffDeferredClaimWithdrawal deferred = + await verification.StaffDeferredClaimWithdrawals.SingleAsync() + .ConfigureAwait(false); + Assert.Equal(nowUtc.AddMinutes(3), deferred.OccurredAtUtc); + Assert.True(deferred.Matches( + withdrawal.ScopeId, + withdrawal.OrganizationId, + withdrawal.EnrollmentLinkId, + withdrawal.ClaimId, + withdrawal.ClaimVersion, + withdrawal.EventId, + withdrawal.OccurredAtUtc)); + } + + using (IServiceScope requestedScope = worker.Services.CreateScope()) + { + requestedScope.ServiceProvider + .GetRequiredService() + .SetTenant(scopeId); + WorkspacesDbContext requestedDb = requestedScope.ServiceProvider + .GetRequiredService(); + IIntegrationEventHandler< + OrganizationEnrollmentClaimChangedIntegrationEvent> handler = + GetHandler( + requestedScope.ServiceProvider, + WorkspacesModuleMetadata.EnrollmentClaimChangedHandlerName); + await HandleInTransactionAsync( + requestedDb, + () => handler.HandleAsync( + new OrganizationEnrollmentClaimChangedIntegrationEvent( + Guid.NewGuid(), + nowUtc.AddMinutes(2), + scopeId, + organizationId, + linkId, + claimId, + subjectId, + OrganizationEnrollmentClaimChange.Requested, + OrganizationEnrollmentClaimStatus.Pending, + membershipId: null, + claimVersion: 1), + CancellationToken.None)).ConfigureAwait(false); + } + + Guid racingLinkId = Guid.NewGuid(); + Guid racingClaimId = Guid.NewGuid(); + string racingSubjectId = Guid.NewGuid().ToString("D"); + using (IServiceScope raceSeedScope = worker.Services.CreateScope()) + { + raceSeedScope.ServiceProvider + .GetRequiredService() + .SetTenant(scopeId); + WorkspacesDbContext raceSeed = raceSeedScope.ServiceProvider + .GetRequiredService(); + (WorkspaceStaffOnboarding application, WorkspaceStaffAccessPlan plan) = + CreateUnboundApplicationAndPlan( + scopeId, + racingLinkId, + racingSubjectId, + nowUtc, + "racing"); + raceSeed.StaffOnboardingApplications.Add(application); + raceSeed.StaffAccessPlans.Add(plan); + await raceSeed.SaveChangesAsync().ConfigureAwait(false); + } + + using IServiceScope racingRequestedScope = worker.Services.CreateScope(); + racingRequestedScope.ServiceProvider + .GetRequiredService() + .SetTenant(scopeId); + WorkspacesDbContext racingRequestedDb = racingRequestedScope + .ServiceProvider.GetRequiredService(); + await using var racingRequestedTransaction = await racingRequestedDb + .Database.BeginTransactionAsync().ConfigureAwait(false); + await racingRequestedScope.ServiceProvider + .GetRequiredService() + .AcquireSourceWriteAsync( + racingLinkId, + CancellationToken.None).ConfigureAwait(false); + + TaskCompletionSource withdrawalBackend = new( + TaskCreationOptions.RunContinuationsAsynchronously); + Task concurrentWithdrawal = Task.Run(async () => + { + using IServiceScope withdrawalScope = worker.Services.CreateScope(); + withdrawalScope.ServiceProvider + .GetRequiredService() + .SetTenant(scopeId); + WorkspacesDbContext withdrawalDb = withdrawalScope.ServiceProvider + .GetRequiredService(); + await using var transaction = await withdrawalDb.Database + .BeginTransactionAsync().ConfigureAwait(false); + int backendPid = await withdrawalDb.Database.SqlQueryRaw( + "SELECT pg_backend_pid() AS \"Value\"") + .SingleAsync().ConfigureAwait(false); + withdrawalBackend.SetResult(backendPid); + IIntegrationEventHandler< + OrganizationEnrollmentClaimWithdrawnIntegrationEvent> handler = + GetHandler( + withdrawalScope.ServiceProvider, + WorkspacesModuleMetadata.EnrollmentClaimWithdrawnHandlerName); + await handler.HandleAsync( + new OrganizationEnrollmentClaimWithdrawnIntegrationEvent( + Guid.NewGuid(), + nowUtc.AddMinutes(6), + scopeId, + organizationId, + racingLinkId, + racingClaimId, + 2), + CancellationToken.None).ConfigureAwait(false); + await withdrawalDb.SaveChangesAsync().ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); + }); + int withdrawalBackendPid = await withdrawalBackend.Task + .WaitAsync(TimeSpan.FromSeconds(5)); + bool waitingForSourceLock = false; + for (int attempt = 0; attempt < 100 && !waitingForSourceLock; attempt++) + { + waitingForSourceLock = await racingRequestedDb.Database + .SqlQueryRaw( + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity " + + "WHERE pid = {0} AND wait_event_type = 'Lock') AS \"Value\"", + withdrawalBackendPid) + .SingleAsync().ConfigureAwait(false); + if (!waitingForSourceLock) + { + await Task.Delay(25).ConfigureAwait(false); + } + } + + if (!waitingForSourceLock) + { + await racingRequestedTransaction.RollbackAsync().ConfigureAwait(false); + await concurrentWithdrawal.WaitAsync(TimeSpan.FromSeconds(10)) + .ConfigureAwait(false); + Assert.Fail("The withdrawal handler never reached the source lock wait."); + } + + IIntegrationEventHandler + racingRequestedHandler = + GetHandler( + racingRequestedScope.ServiceProvider, + WorkspacesModuleMetadata.EnrollmentClaimChangedHandlerName); + await racingRequestedHandler.HandleAsync( + new OrganizationEnrollmentClaimChangedIntegrationEvent( + Guid.NewGuid(), + nowUtc.AddMinutes(5), + scopeId, + organizationId, + racingLinkId, + racingClaimId, + racingSubjectId, + OrganizationEnrollmentClaimChange.Requested, + OrganizationEnrollmentClaimStatus.Pending, + membershipId: null, + claimVersion: 1), + CancellationToken.None).ConfigureAwait(false); + await racingRequestedDb.SaveChangesAsync().ConfigureAwait(false); + await racingRequestedTransaction.CommitAsync().ConfigureAwait(false); + await concurrentWithdrawal.WaitAsync(TimeSpan.FromSeconds(10)) + .ConfigureAwait(false); + + using IServiceScope finalScope = worker.Services.CreateScope(); + finalScope.ServiceProvider.GetRequiredService() + .SetTenant(scopeId); + WorkspacesDbContext finalDb = finalScope.ServiceProvider + .GetRequiredService(); + WorkspaceStaffOnboarding[] terminal = await finalDb + .StaffOnboardingApplications.OrderBy(application => application.Id) + .ToArrayAsync().ConfigureAwait(false); + Assert.Equal(2, terminal.Length); + Assert.All(terminal, application => + { + Assert.Equal(WorkspaceStaffOnboardingState.Withdrawn, application.Status); + Assert.Null(application.VerifiedAccountEmail); + Assert.Null(application.DisplayName); + }); + Assert.Empty(await finalDb.StaffDeferredClaimWithdrawals.ToArrayAsync() + .ConfigureAwait(false)); + } + [DockerFact] [Trait("Category", "Docker")] [Trait("Category", "Integration")] @@ -330,4 +591,65 @@ private static IHost CreateWorker(string connectionString) Assert.True(result.IsValid, result.Report); return builder.Build(); } + + private static (WorkspaceStaffOnboarding Application, WorkspaceStaffAccessPlan Plan) + CreateUnboundApplicationAndPlan( + string scopeId, + Guid linkId, + string subjectId, + DateTimeOffset nowUtc, + string label) + { + WorkspaceStaffOnboarding application = WorkspaceStaffOnboarding.Create( + Guid.NewGuid(), + scopeId, + WorkspaceStaffOnboardingSource.EnrollmentLink, + linkId, + subjectId, + $"{label}@example.test", + $"{label} Applicant", + $"{label} Applicant Legal", + $"{label}.staff@example.test", + "+1 555 0177", + $"EMP-{label}", + "Receptionist", + "Front desk", + nowUtc).Value; + WorkspaceStaffAccessPlan plan = WorkspaceStaffAccessPlan.Create( + linkId, + scopeId, + WorkspaceStaffOnboardingSource.EnrollmentLink, + Guid.NewGuid(), + "front-desk", + [], + Guid.NewGuid().ToString("D"), + nowUtc).Value; + Assert.True(plan.Activate(nowUtc.AddMinutes(1)).IsSuccess); + return (application, plan); + } + + private static IIntegrationEventHandler GetHandler( + IServiceProvider services, + string handlerName) + where TIntegrationEvent : IntegrationEvent + { + IIntegrationEventSubscriptionRegistry subscriptions = services + .GetRequiredService(); + Type handlerType = subscriptions.Subscriptions.Single(subscription => + subscription.ConsumerModule == WorkspacesModuleMetadata.Name && + subscription.HandlerName == handlerName).HandlerType; + return (IIntegrationEventHandler)services + .GetRequiredService(handlerType); + } + + private static async Task HandleInTransactionAsync( + WorkspacesDbContext dbContext, + Func handle) + { + await using var transaction = await dbContext.Database + .BeginTransactionAsync().ConfigureAwait(false); + await handle().ConfigureAwait(false); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); + } } diff --git a/tests/Integration.Tests/Workspaces/WorkspacesDataRightsExportIntegrationTests.Destroy.cs b/tests/Integration.Tests/Workspaces/WorkspacesDataRightsExportIntegrationTests.Destroy.cs index 9b1a8a3b..8ce3574c 100644 --- a/tests/Integration.Tests/Workspaces/WorkspacesDataRightsExportIntegrationTests.Destroy.cs +++ b/tests/Integration.Tests/Workspaces/WorkspacesDataRightsExportIntegrationTests.Destroy.cs @@ -68,9 +68,10 @@ await AssertHistoricalReceiptIsAppendOnlyAsync( TenantB); Guid tenantBOnboardingId; Guid tenantBProjectionId; + Guid tenantBDeferredClaimId; using (IServiceScope tenantBSeedScope = tenantBProvider.CreateScope()) { - (tenantBOnboardingId, tenantBProjectionId) = + (tenantBOnboardingId, tenantBProjectionId, tenantBDeferredClaimId) = await SeedOtherTenantGraphAsync( tenantBSeedScope.ServiceProvider) .ConfigureAwait(false); @@ -179,7 +180,7 @@ await outbox.ClaimPendingAsync( Assert.Equal( "workspace.termination.destroyed", completed.ResultCode); - Assert.Equal(522, completed.AffectedCount); + Assert.Equal(1024, completed.AffectedCount); Assert.Equal(1, completed.SelectedProofRevision); Assert.Equal(3, completed.ResultingProofRevision); Assert.Contains(3, progressCounts); @@ -223,7 +224,7 @@ await ReadDestroyOperationCountAsync(ownerContext, TenantA) await ReadDestroyReceiptCountAsync(ownerContext, TenantA) .ConfigureAwait(false)); Assert.Equal( - 522, + 1024, await ReadDestroyReceiptRemovedCountAsync(ownerContext, TenantA) .ConfigureAwait(false)); Assert.Equal( @@ -293,7 +294,7 @@ DELETE FROM workspaces.workspace_termination_fences WorkspacesDbContext tenantB = tenantBVerificationScope .ServiceProvider.GetRequiredService(); Assert.Equal( - 7, + 8, await ReadDestructibleOwnerRecordCountAsync( tenantB, TenantB, @@ -307,6 +308,10 @@ await ReadDestructibleOwnerRecordCountAsync( tenantBProjectionId, (await tenantB.PropertyProjections.SingleAsync() .ConfigureAwait(false)).Id); + Assert.Equal( + tenantBDeferredClaimId, + (await tenantB.StaffDeferredClaimWithdrawals.SingleAsync() + .ConfigureAwait(false)).Id); } } @@ -456,6 +461,15 @@ private static async Task SeedDenseDestroyStateAsync( $"Dense property {index + 1}", PropertyStatus.Active, version: 1)); + context.StaffDeferredClaimWithdrawals.Add( + WorkspaceStaffDeferredClaimWithdrawal.Create( + TenantA, + Guid.Parse(TenantA), + Guid.Parse("d1100000-0000-0000-0000-000000000001"), + DenseGuid(index, 0xd2), + claimVersion: index + 1, + DenseGuid(index, 0xd3), + clock.UtcNow.AddTicks(index)).Value); } OutboxMessage outbox = new( @@ -532,7 +546,10 @@ private static async Task SeedDenseDestroyStateAsync( await context.SaveChangesAsync().ConfigureAwait(false); } - private static async Task<(Guid OnboardingId, Guid ProjectionId)> + private static async Task<( + Guid OnboardingId, + Guid ProjectionId, + Guid DeferredClaimId)> SeedOtherTenantGraphAsync(IServiceProvider services) { WorkspacesDbContext context = services @@ -600,11 +617,23 @@ private static async Task SeedDenseDestroyStateAsync( Now).Value; Guid projectionId = Guid.Parse("eb000000-0000-0000-0000-000000000001"); + Guid deferredClaimId = + Guid.Parse("ec000000-0000-0000-0000-000000000001"); + WorkspaceStaffDeferredClaimWithdrawal deferred = + WorkspaceStaffDeferredClaimWithdrawal.Create( + TenantB, + Guid.Parse(TenantB), + Guid.Parse("ed000000-0000-0000-0000-000000000001"), + deferredClaimId, + claimVersion: 2, + Guid.Parse("ee000000-0000-0000-0000-000000000001"), + Now).Value; context.AddRange( onboarding, process, plan, retention, + deferred, new WorkspacePropertyProjection( TenantB, projectionId, @@ -612,7 +641,7 @@ private static async Task SeedDenseDestroyStateAsync( PropertyStatus.Active, version: 1)); await context.SaveChangesAsync().ConfigureAwait(false); - return (onboardingId, projectionId); + return (onboardingId, projectionId, deferredClaimId); } private static async Task AssertHistoricalReceiptIsAppendOnlyAsync( @@ -739,6 +768,7 @@ INNER JOIN workspaces.staff_access_processes process WHERE process."ScopeId" = {tenantId}) + (SELECT COUNT(*) FROM workspaces.staff_access_processes WHERE "ScopeId" = {tenantId}) + (SELECT COUNT(*) FROM workspaces.staff_onboarding_applications WHERE "ScopeId" = {tenantId}) + + (SELECT COUNT(*) FROM workspaces.staff_deferred_claim_withdrawals WHERE "ScopeId" = {tenantId}) + (SELECT COUNT(*) FROM workspaces.staff_retention_correlation_receipts WHERE "ScopeId" = {tenantId}) + (SELECT COUNT(*) FROM workspaces.staff_correlation_anonymisation_restore_receipts WHERE "ScopeId" = {tenantId}) + (SELECT COUNT(*) FROM workspaces.staff_correlation_anonymisation_tombstones WHERE "ScopeId" = {tenantId}) + diff --git a/tests/Integration.Tests/Workspaces/WorkspacesDataRightsExportIntegrationTests.cs b/tests/Integration.Tests/Workspaces/WorkspacesDataRightsExportIntegrationTests.cs index a69a889a..a67dfa54 100644 --- a/tests/Integration.Tests/Workspaces/WorkspacesDataRightsExportIntegrationTests.cs +++ b/tests/Integration.Tests/Workspaces/WorkspacesDataRightsExportIntegrationTests.cs @@ -96,6 +96,15 @@ await dbContext.Database.GetService() jobTitle: null, department: null, Now).Value); + dbContext.StaffDeferredClaimWithdrawals.Add( + WorkspaceStaffDeferredClaimWithdrawal.Create( + TenantB, + Guid.Parse(TenantB), + Guid.Parse("40000000-0000-0000-0000-000000000097"), + Guid.Parse("50000000-0000-0000-0000-000000000097"), + claimVersion: 2, + Guid.Parse("60000000-0000-0000-0000-000000000097"), + Now).Value); await dbContext.SaveChangesAsync(); } @@ -396,12 +405,13 @@ await contributor.ExportAsync( TenantTerminationContributionStatus.Completed, result.Status); Assert.Equal("workspace.termination.exported", result.ResultCode); - Assert.Equal(8, result.AffectedCount); + Assert.Equal(9, result.AffectedCount); Assert.Equal(fence.Version, result.SelectedProofRevision); Assert.Equal(fence.Version, result.ResultingProofRevision); Assert.Equal( [ WorkspacesDataRightsCoordinates.StaffOnboardingRecordType, + "staff-deferred-claim-withdrawal", WorkspacesDataRightsCoordinates.StaffAccessProcessRecordType, "staff-access-profile-snapshot", "staff-access-profile-snapshot", @@ -412,6 +422,29 @@ await contributor.ExportAsync( .StaffRetentionCorrelationReceiptRecordType ], first.Records.Select(record => record.RecordType).ToArray()); + DataRightsExportRecord deferred = first.Records[1]; + Assert.Equal( + Guid.Parse("50000000-0000-0000-0000-000000000096"), + deferred.RecordId); + Assert.Equal(2, deferred.RecordVersion); + Assert.Equal( + [ + "workspaces.enrollment-claim-id", + "workspaces.enrollment-claim-version", + "workspaces.integration-event-id", + "workspaces.integration-event-occurred-at", + "workspaces.join-source-id", + "workspaces.workspace-scope-id" + ], + deferred.Fields.Select(field => field.FieldId) + .Order(StringComparer.Ordinal) + .ToArray()); + Assert.Equal( + TenantA, + Field(deferred, "workspaces.workspace-scope-id").GetString()); + Assert.Equal( + Guid.Parse("40000000-0000-0000-0000-000000000096"), + Field(deferred, "workspaces.join-source-id").GetGuid()); CollectingSink replay = new(); TenantTerminationContributionResult replayResult = @@ -428,6 +461,10 @@ await contributor.ExportAsync( replay.Records, record => record.RecordId == Guid.Parse("50000000-0000-0000-0000-000000000099")); + Assert.DoesNotContain( + replay.Records, + record => record.RecordId == + Guid.Parse("50000000-0000-0000-0000-000000000097")); await AssertExportSerializesOperationalMutationAsync( contributor, @@ -654,8 +691,18 @@ private static SeededGraph SeedGraph( accessProcessRecordsScrubbed: 1, accessPlanRecordsScrubbed: 1, Now.AddDays(1)).Value; + WorkspaceStaffDeferredClaimWithdrawal deferred = + WorkspaceStaffDeferredClaimWithdrawal.Create( + tenantId, + Guid.Parse(tenantId), + Guid.Parse("40000000-0000-0000-0000-000000000096"), + Guid.Parse("50000000-0000-0000-0000-000000000096"), + claimVersion: 2, + Guid.Parse("60000000-0000-0000-0000-000000000096"), + Now.AddHours(1)).Value; dbContext.StaffOnboardingApplications.Add(onboarding); + dbContext.StaffDeferredClaimWithdrawals.Add(deferred); dbContext.StaffAccessProcesses.Add(process); dbContext.StaffAccessPlans.Add(plan); dbContext.StaffRetentionCorrelationReceipts.Add(receipt); diff --git a/tests/Integration.Tests/Workspaces/WorkspacesPersistenceIntegrationTests.cs b/tests/Integration.Tests/Workspaces/WorkspacesPersistenceIntegrationTests.cs index 0be091d6..cc237f06 100644 --- a/tests/Integration.Tests/Workspaces/WorkspacesPersistenceIntegrationTests.cs +++ b/tests/Integration.Tests/Workspaces/WorkspacesPersistenceIntegrationTests.cs @@ -24,9 +24,251 @@ public sealed class WorkspacesPersistenceIntegrationTests "20260721203218_ScopeWorkspaceStaffAccessSnapshots"; private const string StaffOnboardingCorrectionsMigration = "20260730104955_AddWorkspaceStaffOnboardingDataRightsCorrections"; + private const string WorkspaceStaffWithdrawalMigration = + "20260809155756_AddWorkspaceStaffOnboardingWithdrawal"; + private const string WorkspaceStaffDeferredWithdrawalMigration = + "20260811044039_AddWorkspaceStaffDeferredClaimWithdrawals"; private const string TenantA = "tenant-a"; private const string TenantB = "tenant-b"; + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Deferred_withdrawal_migration_upgrades_roundtrips_and_refuses_lossy_down() + { + await using PostgreSqlContainer postgreSql = new PostgreSqlBuilder( + "postgres:16-alpine") + .WithDatabase("bunkfy_workspaces_deferred_migration_tests") + .Build(); + await postgreSql.StartAsync(); + string tenantA = Guid.NewGuid().ToString("D"); + string tenantB = Guid.NewGuid().ToString("D"); + Guid organizationId = Guid.Parse(tenantA); + Guid linkId = Guid.NewGuid(); + Guid claimId = Guid.NewGuid(); + Guid eventId = Guid.NewGuid(); + DateTimeOffset occurredAtUtc = new DateTimeOffset( + 2026, + 8, + 11, + 10, + 0, + 0, + TimeSpan.Zero).AddTicks(1); + + await using (WorkspacesDbContext previous = CreateDbContext( + postgreSql.GetConnectionString(), tenantA)) + { + await previous.Database.GetService().MigrateAsync( + WorkspaceStaffWithdrawalMigration); + } + + await using (WorkspacesDbContext upgraded = CreateDbContext( + postgreSql.GetConnectionString(), tenantA)) + { + await upgraded.Database.MigrateAsync(); + await upgraded.Database.MigrateAsync(); + string downScript = upgraded.Database.GetService() + .GenerateScript( + WorkspaceStaffDeferredWithdrawalMigration, + WorkspaceStaffWithdrawalMigration); + int lockOrdinal = downScript.IndexOf( + "LOCK TABLE workspaces.staff_deferred_claim_withdrawals", + StringComparison.Ordinal); + int dropOrdinal = downScript.IndexOf( + "DROP TABLE workspaces.staff_deferred_claim_withdrawals", + StringComparison.Ordinal); + Assert.True(lockOrdinal >= 0); + Assert.True(dropOrdinal > lockOrdinal); + string[] indexes = await upgraded.Database.SqlQueryRaw( + """ + SELECT indexname AS "Value" + FROM pg_indexes + WHERE schemaname = 'workspaces' + AND tablename = 'staff_deferred_claim_withdrawals' + ORDER BY indexname + """) + .ToArrayAsync(); + Assert.Contains( + "IX_staff_deferred_claim_withdrawals_ScopeId_ClaimId", + indexes); + Assert.Contains( + "IX_staff_deferred_claim_withdrawals_ScopeId_EnrollmentLinkId_C~", + indexes); + + PostgresException mismatchedScope = + await Assert.ThrowsAsync(() => + upgraded.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO workspaces.staff_deferred_claim_withdrawals ( + "ClaimId", "OrganizationId", "EnrollmentLinkId", + "ClaimVersion", "EventId", "OccurredAtUtc", "ScopeId") + VALUES ({Guid.NewGuid()}, {organizationId}, {linkId}, + {2L}, {Guid.NewGuid()}, {occurredAtUtc}, {tenantB}) + """)); + Assert.Equal("23514", mismatchedScope.SqlState); + PostgresException emptyCoordinate = + await Assert.ThrowsAsync(() => + upgraded.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO workspaces.staff_deferred_claim_withdrawals ( + "ClaimId", "OrganizationId", "EnrollmentLinkId", + "ClaimVersion", "EventId", "OccurredAtUtc", "ScopeId") + VALUES ({Guid.NewGuid()}, {organizationId}, {linkId}, + {2L}, {Guid.Empty}, {occurredAtUtc}, {tenantA}) + """)); + Assert.Equal("23514", emptyCoordinate.SqlState); + + upgraded.StaffDeferredClaimWithdrawals.Add( + WorkspaceStaffDeferredClaimWithdrawal.Create( + organizationId.ToString("N").ToUpperInvariant(), + organizationId, + linkId, + claimId, + 2, + eventId, + occurredAtUtc).Value); + await upgraded.SaveChangesAsync(); + } + + await using (WorkspacesDbContext tenantAContext = CreateDbContext( + postgreSql.GetConnectionString(), tenantA)) + { + WorkspaceStaffDeferredClaimWithdrawal roundTripped = + await tenantAContext.StaffDeferredClaimWithdrawals.SingleAsync(); + Assert.Equal(tenantA, roundTripped.ScopeId); + Assert.Equal(occurredAtUtc.AddTicks(-1), roundTripped.OccurredAtUtc); + Assert.True(roundTripped.Matches( + organizationId.ToString("N").ToUpperInvariant(), + organizationId, + linkId, + claimId, + 2, + eventId, + occurredAtUtc)); + } + + await using (WorkspacesDbContext tenantBContext = CreateDbContext( + postgreSql.GetConnectionString(), tenantB)) + { + Assert.Empty(await tenantBContext.StaffDeferredClaimWithdrawals + .ToArrayAsync()); + Assert.Single(await tenantBContext.StaffDeferredClaimWithdrawals + .IgnoreQueryFilters() + .ToArrayAsync()); + } + + await using (WorkspacesDbContext refusedDown = CreateDbContext( + postgreSql.GetConnectionString(), tenantA)) + { + PostgresException refusal = await Assert.ThrowsAsync( + () => refusedDown.Database.GetService().MigrateAsync( + WorkspaceStaffWithdrawalMigration)); + Assert.Equal("P0001", refusal.SqlState); + Assert.Contains( + "Cannot remove durable Staff claim withdrawals", + refusal.MessageText, + StringComparison.Ordinal); + } + + await using (WorkspacesDbContext afterRefusal = CreateDbContext( + postgreSql.GetConnectionString(), tenantA)) + { + Assert.Equal( + claimId, + (await afterRefusal.StaffDeferredClaimWithdrawals.SingleAsync()).Id); + Assert.Equal( + 1, + await afterRefusal.Database.SqlQueryRaw( + """ + SELECT COUNT(*)::int AS "Value" + FROM workspaces.__ef_migrations_history + WHERE "MigrationId" = + '20260811044039_AddWorkspaceStaffDeferredClaimWithdrawals' + """) + .SingleAsync()); + } + + await using (WorkspacesDbContext retained = CreateDbContext( + postgreSql.GetConnectionString(), tenantA)) + { + retained.StaffDeferredClaimWithdrawals.Remove( + await retained.StaffDeferredClaimWithdrawals.SingleAsync()); + await retained.SaveChangesAsync(); + await retained.Database.GetService().MigrateAsync( + WorkspaceStaffWithdrawalMigration); + await retained.Database.MigrateAsync(); + } + + await using WorkspacesDbContext lockContext = CreateDbContext( + postgreSql.GetConnectionString(), tenantA); + await using var lockTransaction = await lockContext.Database + .BeginTransactionAsync(); + await lockContext.Database.ExecuteSqlRawAsync( + "LOCK TABLE workspaces.staff_deferred_claim_withdrawals " + + "IN ACCESS EXCLUSIVE MODE"); + TaskCompletionSource insertBackend = new( + TaskCreationOptions.RunContinuationsAsynchronously); + Task concurrentInsert = Task.Run(async () => + { + await using NpgsqlConnection connection = new( + postgreSql.GetConnectionString()); + await connection.OpenAsync(); + await using (NpgsqlCommand backend = new( + "SELECT pg_backend_pid()", + connection)) + { + insertBackend.SetResult((int)(await backend.ExecuteScalarAsync())!); + } + + await using NpgsqlCommand insert = new( + """ + INSERT INTO workspaces.staff_deferred_claim_withdrawals ( + "ClaimId", "OrganizationId", "EnrollmentLinkId", + "ClaimVersion", "EventId", "OccurredAtUtc", "ScopeId") + VALUES (@claimId, @organizationId, @linkId, 2, @eventId, + @occurredAtUtc, @scopeId) + """, + connection); + insert.Parameters.AddWithValue("claimId", Guid.NewGuid()); + insert.Parameters.AddWithValue("organizationId", organizationId); + insert.Parameters.AddWithValue("linkId", linkId); + insert.Parameters.AddWithValue("eventId", Guid.NewGuid()); + insert.Parameters.AddWithValue("occurredAtUtc", occurredAtUtc); + insert.Parameters.AddWithValue("scopeId", tenantA); + return await insert.ExecuteNonQueryAsync(); + }); + int backendPid = await insertBackend.Task.WaitAsync(TimeSpan.FromSeconds(5)); + bool insertIsWaiting = false; + for (int attempt = 0; attempt < 100 && !insertIsWaiting; attempt++) + { + insertIsWaiting = await lockContext.Database.SqlQueryRaw( + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity " + + "WHERE pid = {0} AND wait_event_type = 'Lock') AS \"Value\"", + backendPid) + .SingleAsync(); + if (!insertIsWaiting) + { + await Task.Delay(25); + } + } + + if (!insertIsWaiting) + { + await lockTransaction.RollbackAsync(); + await concurrentInsert.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Fail( + "The concurrent deferred insert never reached the migration table lock."); + } + + await lockContext.Database.ExecuteSqlRawAsync( + "DROP TABLE workspaces.staff_deferred_claim_withdrawals"); + await lockTransaction.CommitAsync(); + PostgresException insertFailure = await Assert.ThrowsAsync( + async () => { await concurrentInsert; }); + Assert.True( + insertFailure.SqlState is "42P01" or "XX000", + $"Unexpected concurrent-insert SQLSTATE: {insertFailure.SqlState}"); + } + [DockerFact] [Trait("Category", "Docker")] [Trait("Category", "Integration")]