diff --git a/docs/architecture/workspaces-and-onboarding.md b/docs/architecture/workspaces-and-onboarding.md index 1ee742e4..28808ffa 100644 --- a/docs/architecture/workspaces-and-onboarding.md +++ b/docs/architecture/workspaces-and-onboarding.md @@ -220,6 +220,65 @@ BunkFy's current composition uses global Auth identities and no workspace-id log The migration and the steady-state schema must be covered against PostgreSQL. Provider-specific migration code remains outside Auth domain/application behavior. +### Identity-Anchor Stop/Drain Deployment Contract + +The following migrations form one non-rolling-compatible release boundary: + +- Staff `20260811110753_AddStaffIdentityProvisioningAnchors`; +- Workspaces `20260811205004_AddWorkspaceStaffIdentityProvisioningAnchors`. + +The generic [Migrations Host Production Safety](../operations/migrations-host-production-safety.md) +contract still applies. This feature additionally requires one stop-the-world +cutover: + +1. Freeze one reviewed immutable release whose Migrations, API, Admin, and + Worker artifacts have the same source identity and contain the two expected + migration artifacts. Retain the matching Production `Plan` output. +2. Stop every API, Admin, Worker, scheduled-task, and ad hoc maintenance + instance that can write Staff or Workspaces on the target database, + including old replicas and canaries. Block new traffic and task delivery. +3. Wait for active Staff and Workspaces database transactions to finish. Drain + identity-anchor inbox/outbox deliveries and reconciliation tasks to reviewed + safe states: processed/terminal, or an explicitly recorded retry/failed item + whose coordinates and forward-replay owner have been reviewed. Unknown or + still-running work is not a safe drain state. +4. Apply `20260811110753_AddStaffIdentityProvisioningAnchors` first and + `20260811205004_AddWorkspaceStaffIdentityProvisioningAnchors` second from the + same approved Migrations artifact. A partial run remains stopped and uses + the approved same-release forward-resume or restore procedure. +5. Deploy the matching API, Admin, and Worker binaries together before any + ordinary writer resumes. Keep external traffic, schedules, and general + consumer delivery paused while controlled cutover checks run. Once the + Staff migration has committed, no pre-cutover binary may reconnect to the + advanced database. +6. For every tenant, run `workspaces.identity-anchors.status` with the reviewed + owner manifest, reconcile only against the accepted evidence hashes, and + rerun status until it reports a ready, conflict-free state. Complete a + bounded identity-anchor sweep under the stable-universe barrier and verify + its checkpoint, high-water mark, and zero deferred/conflict backlog. + Reconcile the Staff anchor/resolution ledger with Workspaces application and + historical-review receipts, and confirm that identity-anchor inbox/outbox + and task backlogs contain no unreviewed pending, retryable, running, or + failed work. +7. Resume the new Worker consumers and maintenance schedules deliberately, + verify the ledgers and backlogs remain converged, and only then reopen API + and Admin write traffic. + +The `Down` methods are safety guards, not the rollback plan. Staff refuses a +downgrade while durable anchors or resolutions exist. Workspaces refuses while +identity-anchor receipts or checkpoints, active new destruction stages, +onboarding anchor coordinates, suppressed restorations, irreversible +redactions, pending identity-anchor messages, or reconciliation tasks remain. +A guard refusal is a hard stop: keep writers stopped and use reviewed forward +repair or restore the complete pre-change database and artifact set. + +This section defines repository procedure only. Its presence, review, or +passing repository checks is not evidence that any environment was stopped, +drained, migrated, verified, or resumed. Environment-specific evidence must +record the stopped instance set, drain observations, exact artifact identities, +Plan/Apply output, cutover status, sweep/ledger/backlog results, approvals, and +resume decision. + ## Rejected Shapes - Put workspace lifecycle in Tenancy: mixes domain registry behavior into scope plumbing. 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/gma/modules/organizations b/gma/modules/organizations index 50f736d0..e2d0755e 160000 --- a/gma/modules/organizations +++ b/gma/modules/organizations @@ -1 +1 @@ -Subproject commit 50f736d0b815f46e345f86b5c8e7f1866742ed42 +Subproject commit e2d0755e6b3b6f765f120029e40bdbd69e9120ac diff --git a/src/BunkFy.Host.AdminApi/appsettings.json b/src/BunkFy.Host.AdminApi/appsettings.json index 35e0901f..54e27550 100644 --- a/src/BunkFy.Host.AdminApi/appsettings.json +++ b/src/BunkFy.Host.AdminApi/appsettings.json @@ -296,7 +296,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/BunkFy.Host.Worker/appsettings.json b/src/BunkFy.Host.Worker/appsettings.json index f3d89454..bf09ba27 100644 --- a/src/BunkFy.Host.Worker/appsettings.json +++ b/src/BunkFy.Host.Worker/appsettings.json @@ -315,7 +315,7 @@ "Tasks": { "Worker": { "Enabled": false, - "WorkerGroups": [ "default", "projection-workers", "reminder-workers", "ingestion-adapters", "ingestion-maintenance", "data-rights-workers", "retention-workers", "tenant-termination-workers" ], + "WorkerGroups": [ "default", "projection-workers", "reminder-workers", "ingestion-adapters", "ingestion-maintenance", "data-rights-workers", "retention-workers", "tenant-termination-workers", "workspaces-maintenance-workers" ], "BatchSize": 10, "MaxConcurrency": 1, "PollInterval": "00:00:01", 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/BunkFy.Extensions.Workspaces/DependencyInjection.cs b/src/Extensions/BunkFy.Extensions.Workspaces/DependencyInjection.cs index 1d3933d7..931ce538 100644 --- a/src/Extensions/BunkFy.Extensions.Workspaces/DependencyInjection.cs +++ b/src/Extensions/BunkFy.Extensions.Workspaces/DependencyInjection.cs @@ -1,6 +1,7 @@ namespace BunkFy.Extensions.Workspaces; using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Contracts; using Gma.Framework.Messaging; using Gma.Framework.Observability; using Gma.Modules.AccessControl.Contracts; @@ -82,6 +83,11 @@ public static IServiceCollection AddBunkFyWorkspaces( OrganizationMembershipAccessHandler>( AccessControlModuleMetadata.Name, OrganizationsModuleMetadata.Name); + services.AddIntegrationEventHandler< + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent, + WorkspaceStaffOnboardingIdentityAnchorResolutionHandler>( + StaffModuleMetadata.Name, + WorkspacesModuleMetadata.Name); return services; } diff --git a/src/Extensions/BunkFy.Extensions.Workspaces/OrganizationOwnerStaffBootstrapHandler.cs b/src/Extensions/BunkFy.Extensions.Workspaces/OrganizationOwnerStaffBootstrapHandler.cs index 2ce4d33b..a05199af 100644 --- a/src/Extensions/BunkFy.Extensions.Workspaces/OrganizationOwnerStaffBootstrapHandler.cs +++ b/src/Extensions/BunkFy.Extensions.Workspaces/OrganizationOwnerStaffBootstrapHandler.cs @@ -11,7 +11,7 @@ namespace BunkFy.Extensions.Workspaces; internal sealed class OrganizationOwnerStaffBootstrapHandler( IStaffIdentityBootstrapper staff, IAuthMemberContactReader contacts, - IOrganizationAccessDecisionReader organizationAccess, + IOrganizationMembershipInspector memberships, IWorkspaceOperationalAdmissionPolicy operationalAdmission, IOptions options) : IIntegrationEventHandler @@ -29,32 +29,69 @@ public async Task HandleAsync( return; } - WorkspaceOperationalAdmissionDecision admission = - await operationalAdmission.EvaluateAsync( + string canonicalOrganizationId = + integrationEvent.OrganizationId.ToString("D"); + if (integrationEvent.OrganizationId == Guid.Empty || + integrationEvent.MembershipId == Guid.Empty || + integrationEvent.MembershipVersion <= 0 || + string.IsNullOrWhiteSpace(integrationEvent.SubjectId) || + !string.Equals( integrationEvent.ScopeId, - cancellationToken).ConfigureAwait(false); - if (admission.Outcome != WorkspaceOperationalAdmissionOutcome.Allowed) + canonicalOrganizationId, + StringComparison.Ordinal)) { throw new InvalidOperationException( - "Workspace operational admission did not allow owner Staff bootstrap."); + "Organizations owner Staff bootstrap coordinates are invalid."); } - OrganizationAccessDecision access = await organizationAccess.ReadAsync( + OrganizationMembershipSnapshot? snapshot = await memberships.FindAsync( integrationEvent.OrganizationId, + integrationEvent.MembershipId, integrationEvent.SubjectId, cancellationToken).ConfigureAwait(false); - if (access is OrganizationAccessDecision.OrganizationNotFound or - OrganizationAccessDecision.OrganizationInactive or - OrganizationAccessDecision.MembershipNotFound or - OrganizationAccessDecision.MembershipInactive) + if (snapshot is null || + snapshot.OrganizationId != integrationEvent.OrganizationId || + snapshot.MembershipId != integrationEvent.MembershipId || + snapshot.MembershipVersion < integrationEvent.MembershipVersion || + snapshot.ScopeRevision < 0 || + snapshot.OrganizationStatus == OrganizationStatus.Unknown || + snapshot.ScopeStatus is OrganizationScopeStatus.Unknown or + OrganizationScopeStatus.Invalid or + OrganizationScopeStatus.Missing || + snapshot.Role == OrganizationMembershipRole.Unknown || + snapshot.MembershipStatus == OrganizationMembershipStatus.Unknown) + { + throw new InvalidOperationException( + "Organizations membership snapshot is unavailable for owner Staff bootstrap."); + } + + if (snapshot.ScopeStatus == OrganizationScopeStatus.Closed || + snapshot.OrganizationStatus is OrganizationStatus.Suspended or + OrganizationStatus.Archived || + snapshot.Role == OrganizationMembershipRole.Member || + snapshot.MembershipStatus is OrganizationMembershipStatus.Suspended or + OrganizationMembershipStatus.Removed) { return; } - if (access != OrganizationAccessDecision.Allowed) + if (snapshot.ScopeStatus != OrganizationScopeStatus.Open || + snapshot.OrganizationStatus != OrganizationStatus.Active || + snapshot.Role != OrganizationMembershipRole.Owner || + snapshot.MembershipStatus != OrganizationMembershipStatus.Active) { throw new InvalidOperationException( - "Organizations access is unavailable for owner Staff bootstrap."); + "Organizations membership snapshot is invalid for owner Staff bootstrap."); + } + + WorkspaceOperationalAdmissionDecision admission = + await operationalAdmission.EvaluateAsync( + integrationEvent.ScopeId, + cancellationToken).ConfigureAwait(false); + if (admission.Outcome != WorkspaceOperationalAdmissionOutcome.Allowed) + { + throw new InvalidOperationException( + "Workspace operational admission did not allow owner Staff bootstrap."); } string? verifiedEmail = await this.GetVerifiedEmailAsync( @@ -64,6 +101,7 @@ OrganizationAccessDecision.MembershipNotFound or StaffIdentityBootstrapResult result = await staff.BootstrapAsync( new StaffIdentityBootstrapRequest( integrationEvent.EventId, + integrationEvent.MembershipId, integrationEvent.SubjectId, displayName, verifiedEmail, diff --git a/src/Extensions/BunkFy.Extensions.Workspaces/WorkspaceStaffOnboardingIdentityAnchorResolutionHandler.cs b/src/Extensions/BunkFy.Extensions.Workspaces/WorkspaceStaffOnboardingIdentityAnchorResolutionHandler.cs new file mode 100644 index 00000000..cfc0ab53 --- /dev/null +++ b/src/Extensions/BunkFy.Extensions.Workspaces/WorkspaceStaffOnboardingIdentityAnchorResolutionHandler.cs @@ -0,0 +1,70 @@ +namespace BunkFy.Extensions.Workspaces; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Contracts; +using Gma.Framework.Messaging; + +[IntegrationEventHandler(HandlerName, RequiresExplicitProducerBinding = true)] +internal sealed class WorkspaceStaffOnboardingIdentityAnchorResolutionHandler( + IStaffWorkspaceOnboardingIdentityAnchorResolutionRecorder recorder) + : IIntegrationEventHandler< + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent> +{ + public const string HandlerName = + "bunkfy-workspace-staff-identity-anchor-resolution"; + + public async Task HandleAsync( + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent + integrationEvent, + CancellationToken cancellationToken) + { + StaffWorkspaceOnboardingIdentityAnchorResolutionResult recorded = + await recorder.RecordAsync( + new StaffWorkspaceOnboardingIdentityAnchorResolutionRequest( + integrationEvent.EventId, + integrationEvent.ApplicationId, + integrationEvent.StaffMemberId, + integrationEvent.WorkspaceApplicationVersion, + ToStaffDisposition(integrationEvent.Disposition), + integrationEvent.OccurredAtUtc), + cancellationToken).ConfigureAwait(false); + if (recorded.Status is not ( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus.Recorded or + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus + .AlreadyRecorded)) + { + throw new InvalidOperationException( + $"Staff identity-anchor resolution failed with '{recorded.Status}'."); + } + } + + private static + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + ToStaffDisposition( + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + disposition) => disposition switch + { + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted => + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted => + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted => + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted => + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted => + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted, + _ => throw new InvalidOperationException( + "The Workspaces identity-anchor resolution disposition is invalid.") + }; +} 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/Extensions/tests/BunkFy.Extensions.Workspaces.Tests/OrganizationMembershipAccessHandlerTests.cs b/src/Extensions/tests/BunkFy.Extensions.Workspaces.Tests/OrganizationMembershipAccessHandlerTests.cs index 9173cc17..d7ca518b 100644 --- a/src/Extensions/tests/BunkFy.Extensions.Workspaces.Tests/OrganizationMembershipAccessHandlerTests.cs +++ b/src/Extensions/tests/BunkFy.Extensions.Workspaces.Tests/OrganizationMembershipAccessHandlerTests.cs @@ -28,8 +28,17 @@ public void Workspace_subscriptions_preserve_scope_aware_event_metadata() .Select(descriptor => descriptor.ImplementationInstance) .OfType() .ToArray(); - Assert.Equal(2, subscriptions.Length); + Assert.Equal(3, subscriptions.Length); Assert.All(subscriptions, subscription => Assert.True(subscription.IsTenantScoped())); + Assert.Contains( + subscriptions, + subscription => + subscription.EventType == typeof( + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent) && + subscription.HandlerType == typeof( + WorkspaceStaffOnboardingIdentityAnchorResolutionHandler) && + subscription.ConsumerModule == StaffModuleMetadata.Name && + subscription.ProducerModule == WorkspacesModuleMetadata.Name); Assert.Contains(services, descriptor => descriptor.ServiceType == typeof(IAccessProfileAssignmentPolicy) && descriptor.ImplementationType == typeof(WorkspaceAccessProfileAssignmentPolicy)); diff --git a/src/Extensions/tests/BunkFy.Extensions.Workspaces.Tests/OrganizationOwnerStaffBootstrapHandlerTests.cs b/src/Extensions/tests/BunkFy.Extensions.Workspaces.Tests/OrganizationOwnerStaffBootstrapHandlerTests.cs index 4761e7e2..95789118 100644 --- a/src/Extensions/tests/BunkFy.Extensions.Workspaces.Tests/OrganizationOwnerStaffBootstrapHandlerTests.cs +++ b/src/Extensions/tests/BunkFy.Extensions.Workspaces.Tests/OrganizationOwnerStaffBootstrapHandlerTests.cs @@ -11,179 +11,359 @@ namespace BunkFy.Extensions.Workspaces.Tests; public sealed class OrganizationOwnerStaffBootstrapHandlerTests { [Fact] - public async Task Active_member_membership_does_not_create_a_staff_profile() + public async Task Non_candidate_event_does_not_read_current_snapshot() { RecordingStaffIdentityBootstrapper staff = new(); RecordingContactReader contacts = new(); - StubOrganizationAccessDecisionReader access = new(); - OrganizationOwnerStaffBootstrapHandler handler = new( + RecordingMembershipInspector memberships = new(_ => null); + StubWorkspaceOperationalAdmissionPolicy admission = new(); + OrganizationOwnerStaffBootstrapHandler handler = CreateHandler( staff, contacts, - access, - new StubWorkspaceOperationalAdmissionPolicy(), - Options.Create(new BunkFyWorkspacesOptions { GlobalAuthScopeId = "global" })); + memberships, + admission); await handler.HandleAsync( - CreateEvent(OrganizationMembershipRole.Member, OrganizationMembershipStatus.Active), + CreateEvent( + OrganizationMembershipRole.Member, + OrganizationMembershipStatus.Active), CancellationToken.None); + Assert.Empty(memberships.Calls); + Assert.Empty(admission.TenantIds); Assert.Empty(staff.Requests); Assert.Equal(0, contacts.CallCount); - Assert.Equal(0, access.CallCount); } [Fact] - public async Task Owner_membership_still_provisions_the_bootstrap_staff_profile() + public async Task Exact_current_owner_snapshot_provisions_stable_membership_source() { RecordingStaffIdentityBootstrapper staff = new(); RecordingContactReader contacts = new(); - StubOrganizationAccessDecisionReader access = new(); - OrganizationOwnerStaffBootstrapHandler handler = new( + OrganizationMembershipChangedIntegrationEvent integrationEvent = + CreateEvent( + OrganizationMembershipRole.Owner, + OrganizationMembershipStatus.Active); + RecordingMembershipInspector memberships = new(call => + ActiveSnapshot( + integrationEvent, + membershipVersion: integrationEvent.MembershipVersion + 1, + scopeRevision: 0)); + StubWorkspaceOperationalAdmissionPolicy admission = new(); + OrganizationOwnerStaffBootstrapHandler handler = CreateHandler( staff, contacts, - access, - new StubWorkspaceOperationalAdmissionPolicy(), - Options.Create(new BunkFyWorkspacesOptions { GlobalAuthScopeId = "global" })); + memberships, + admission); - OrganizationMembershipChangedIntegrationEvent integrationEvent = CreateEvent( - OrganizationMembershipRole.Owner, - OrganizationMembershipStatus.Active); await handler.HandleAsync(integrationEvent, CancellationToken.None); + MembershipInspectionCall call = Assert.Single(memberships.Calls); + Assert.Equal(integrationEvent.OrganizationId, call.OrganizationId); + Assert.Equal(integrationEvent.MembershipId, call.MembershipId); + Assert.Equal(integrationEvent.SubjectId, call.SubjectId); + Assert.Equal(integrationEvent.ScopeId, Assert.Single(admission.TenantIds)); StaffIdentityBootstrapRequest request = Assert.Single(staff.Requests); Assert.Equal(integrationEvent.EventId, request.OperationId); + Assert.Equal(integrationEvent.MembershipId, request.SourceId); Assert.Equal(integrationEvent.SubjectId, request.AuthSubjectId); Assert.Equal("owner@example.test", request.WorkEmail); - Assert.Equal(1, access.CallCount); } [Theory] [InlineData(OrganizationMembershipChange.Suspended, OrganizationMembershipStatus.Suspended)] [InlineData(OrganizationMembershipChange.Resumed, OrganizationMembershipStatus.Active)] [InlineData(OrganizationMembershipChange.Removed, OrganizationMembershipStatus.Removed)] - public async Task Membership_lifecycle_changes_do_not_mutate_staff( + public async Task Non_join_membership_changes_do_not_read_or_mutate( OrganizationMembershipChange change, OrganizationMembershipStatus status) { RecordingStaffIdentityBootstrapper staff = new(); RecordingContactReader contacts = new(); - OrganizationOwnerStaffBootstrapHandler handler = new( + RecordingMembershipInspector memberships = new(_ => null); + StubWorkspaceOperationalAdmissionPolicy admission = new(); + OrganizationOwnerStaffBootstrapHandler handler = CreateHandler( staff, contacts, - new StubOrganizationAccessDecisionReader(), - new StubWorkspaceOperationalAdmissionPolicy(), - Options.Create(new BunkFyWorkspacesOptions { GlobalAuthScopeId = "global" })); + memberships, + admission); await handler.HandleAsync( CreateEvent(OrganizationMembershipRole.Owner, status, change), CancellationToken.None); + Assert.Empty(memberships.Calls); + Assert.Empty(admission.TenantIds); Assert.Empty(staff.Requests); Assert.Equal(0, contacts.CallCount); } [Fact] - public async Task Restricted_workspace_retries_owner_bootstrap_without_side_effects() + public async Task Non_canonical_scope_coordinate_retries_before_snapshot_read() { + OrganizationMembershipChangedIntegrationEvent invalid = CreateEvent( + OrganizationMembershipRole.Owner, + OrganizationMembershipStatus.Active, + canonicalScope: false); + RecordingMembershipInspector memberships = new(_ => null); RecordingStaffIdentityBootstrapper staff = new(); RecordingContactReader contacts = new(); - OrganizationOwnerStaffBootstrapHandler handler = new( + StubWorkspaceOperationalAdmissionPolicy admission = new(); + OrganizationOwnerStaffBootstrapHandler handler = CreateHandler( staff, contacts, - new StubOrganizationAccessDecisionReader(), - new StubWorkspaceOperationalAdmissionPolicy( - WorkspaceOperationalAdmissionOutcome.Restricted), - Options.Create(new BunkFyWorkspacesOptions { GlobalAuthScopeId = "global" })); + memberships, + admission); await Assert.ThrowsAsync(() => - handler.HandleAsync( - CreateEvent( - OrganizationMembershipRole.Owner, - OrganizationMembershipStatus.Active), - CancellationToken.None)); + handler.HandleAsync(invalid, CancellationToken.None)); + Assert.Empty(memberships.Calls); + Assert.Empty(admission.TenantIds); Assert.Empty(staff.Requests); Assert.Equal(0, contacts.CallCount); } + [Fact] + public async Task Null_or_older_snapshot_retries_without_downstream_reads() + { + OrganizationMembershipChangedIntegrationEvent integrationEvent = + CreateEvent( + OrganizationMembershipRole.Owner, + OrganizationMembershipStatus.Active, + membershipVersion: 4); + foreach (OrganizationMembershipSnapshot? snapshot in new[] + { + null, + ActiveSnapshot( + integrationEvent, + membershipVersion: 3, + scopeRevision: 1) + }) + { + RecordingStaffIdentityBootstrapper staff = new(); + RecordingContactReader contacts = new(); + RecordingMembershipInspector memberships = new(_ => snapshot); + StubWorkspaceOperationalAdmissionPolicy admission = new(); + OrganizationOwnerStaffBootstrapHandler handler = CreateHandler( + staff, + contacts, + memberships, + admission); + + await Assert.ThrowsAsync(() => + handler.HandleAsync( + integrationEvent, + CancellationToken.None)); + + Assert.Single(memberships.Calls); + Assert.Empty(admission.TenantIds); + Assert.Empty(staff.Requests); + Assert.Equal(0, contacts.CallCount); + } + } + [Theory] - [InlineData(OrganizationAccessDecision.OrganizationNotFound)] - [InlineData(OrganizationAccessDecision.OrganizationInactive)] - [InlineData(OrganizationAccessDecision.MembershipNotFound)] - [InlineData(OrganizationAccessDecision.MembershipInactive)] - public async Task Stale_owner_event_is_acknowledged_before_contact_lookup( - OrganizationAccessDecision decision) + [InlineData(OrganizationScopeStatus.Unknown, OrganizationStatus.Active, OrganizationMembershipRole.Owner, OrganizationMembershipStatus.Active, 1)] + [InlineData(OrganizationScopeStatus.Invalid, OrganizationStatus.Active, OrganizationMembershipRole.Owner, OrganizationMembershipStatus.Active, 1)] + [InlineData(OrganizationScopeStatus.Missing, OrganizationStatus.Active, OrganizationMembershipRole.Owner, OrganizationMembershipStatus.Active, 1)] + [InlineData(OrganizationScopeStatus.Open, OrganizationStatus.Unknown, OrganizationMembershipRole.Owner, OrganizationMembershipStatus.Active, 1)] + [InlineData(OrganizationScopeStatus.Open, OrganizationStatus.Active, OrganizationMembershipRole.Unknown, OrganizationMembershipStatus.Active, 1)] + [InlineData(OrganizationScopeStatus.Open, OrganizationStatus.Active, OrganizationMembershipRole.Owner, OrganizationMembershipStatus.Unknown, 1)] + [InlineData(OrganizationScopeStatus.Open, OrganizationStatus.Active, OrganizationMembershipRole.Owner, OrganizationMembershipStatus.Active, -1)] + public async Task Unknown_or_invalid_snapshot_retries( + OrganizationScopeStatus scopeStatus, + OrganizationStatus organizationStatus, + OrganizationMembershipRole role, + OrganizationMembershipStatus membershipStatus, + long scopeRevision) { + OrganizationMembershipChangedIntegrationEvent integrationEvent = + CreateEvent( + OrganizationMembershipRole.Owner, + OrganizationMembershipStatus.Active); + OrganizationMembershipSnapshot snapshot = new( + integrationEvent.OrganizationId, + integrationEvent.MembershipId, + organizationStatus, + scopeStatus, + scopeRevision, + role, + membershipStatus, + integrationEvent.MembershipVersion); RecordingStaffIdentityBootstrapper staff = new(); RecordingContactReader contacts = new(); - OrganizationOwnerStaffBootstrapHandler handler = new( + RecordingMembershipInspector memberships = new(_ => snapshot); + StubWorkspaceOperationalAdmissionPolicy admission = new(); + OrganizationOwnerStaffBootstrapHandler handler = CreateHandler( staff, contacts, - new StubOrganizationAccessDecisionReader(decision), - new StubWorkspaceOperationalAdmissionPolicy(), - Options.Create(new BunkFyWorkspacesOptions - { - GlobalAuthScopeId = "global" - })); + memberships, + admission); - await handler.HandleAsync( + await Assert.ThrowsAsync(() => + handler.HandleAsync(integrationEvent, CancellationToken.None)); + + Assert.Empty(admission.TenantIds); + Assert.Empty(staff.Requests); + Assert.Equal(0, contacts.CallCount); + } + + [Theory] + [InlineData(OrganizationScopeStatus.Closed, OrganizationStatus.Active, OrganizationMembershipRole.Owner, OrganizationMembershipStatus.Active)] + [InlineData(OrganizationScopeStatus.Open, OrganizationStatus.Suspended, OrganizationMembershipRole.Owner, OrganizationMembershipStatus.Active)] + [InlineData(OrganizationScopeStatus.Open, OrganizationStatus.Archived, OrganizationMembershipRole.Owner, OrganizationMembershipStatus.Active)] + [InlineData(OrganizationScopeStatus.Open, OrganizationStatus.Active, OrganizationMembershipRole.Member, OrganizationMembershipStatus.Active)] + [InlineData(OrganizationScopeStatus.Open, OrganizationStatus.Active, OrganizationMembershipRole.Owner, OrganizationMembershipStatus.Suspended)] + [InlineData(OrganizationScopeStatus.Open, OrganizationStatus.Active, OrganizationMembershipRole.Owner, OrganizationMembershipStatus.Removed)] + public async Task Current_terminal_or_non_owner_state_is_acknowledged( + OrganizationScopeStatus scopeStatus, + OrganizationStatus organizationStatus, + OrganizationMembershipRole role, + OrganizationMembershipStatus membershipStatus) + { + OrganizationMembershipChangedIntegrationEvent integrationEvent = CreateEvent( OrganizationMembershipRole.Owner, - OrganizationMembershipStatus.Active), - CancellationToken.None); + OrganizationMembershipStatus.Active); + OrganizationMembershipSnapshot snapshot = new( + integrationEvent.OrganizationId, + integrationEvent.MembershipId, + organizationStatus, + scopeStatus, + 1, + role, + membershipStatus, + integrationEvent.MembershipVersion); + RecordingStaffIdentityBootstrapper staff = new(); + RecordingContactReader contacts = new(); + RecordingMembershipInspector memberships = new(_ => snapshot); + StubWorkspaceOperationalAdmissionPolicy admission = new(); + OrganizationOwnerStaffBootstrapHandler handler = CreateHandler( + staff, + contacts, + memberships, + admission); + + await handler.HandleAsync(integrationEvent, CancellationToken.None); + Assert.Single(memberships.Calls); + Assert.Empty(admission.TenantIds); Assert.Empty(staff.Requests); Assert.Equal(0, contacts.CallCount); } - [Theory] - [InlineData(OrganizationAccessDecision.Unknown)] - [InlineData(OrganizationAccessDecision.Unavailable)] - public async Task Unavailable_owner_authority_retries_without_side_effects( - OrganizationAccessDecision decision) + [Fact] + public async Task Restricted_workspace_retries_after_current_owner_admission() { + OrganizationMembershipChangedIntegrationEvent integrationEvent = + CreateEvent( + OrganizationMembershipRole.Owner, + OrganizationMembershipStatus.Active); + RecordingMembershipInspector memberships = new(_ => + ActiveSnapshot( + integrationEvent, + integrationEvent.MembershipVersion, + scopeRevision: 1)); RecordingStaffIdentityBootstrapper staff = new(); RecordingContactReader contacts = new(); - OrganizationOwnerStaffBootstrapHandler handler = new( + StubWorkspaceOperationalAdmissionPolicy admission = new( + WorkspaceOperationalAdmissionOutcome.Restricted); + OrganizationOwnerStaffBootstrapHandler handler = CreateHandler( staff, contacts, - new StubOrganizationAccessDecisionReader(decision), - new StubWorkspaceOperationalAdmissionPolicy(), - Options.Create(new BunkFyWorkspacesOptions - { - GlobalAuthScopeId = "global" - })); + memberships, + admission); await Assert.ThrowsAsync(() => - handler.HandleAsync( - CreateEvent( - OrganizationMembershipRole.Owner, - OrganizationMembershipStatus.Active), - CancellationToken.None)); + handler.HandleAsync(integrationEvent, CancellationToken.None)); + Assert.Single(memberships.Calls); + Assert.Single(admission.TenantIds); Assert.Empty(staff.Requests); Assert.Equal(0, contacts.CallCount); } + [Fact] + public async Task Mismatched_snapshot_coordinates_retry() + { + OrganizationMembershipChangedIntegrationEvent integrationEvent = + CreateEvent( + OrganizationMembershipRole.Owner, + OrganizationMembershipStatus.Active); + OrganizationMembershipSnapshot snapshot = ActiveSnapshot( + integrationEvent, + integrationEvent.MembershipVersion, + scopeRevision: 1) with + { + MembershipId = Guid.NewGuid() + }; + RecordingStaffIdentityBootstrapper staff = new(); + RecordingContactReader contacts = new(); + RecordingMembershipInspector memberships = new(_ => snapshot); + StubWorkspaceOperationalAdmissionPolicy admission = new(); + OrganizationOwnerStaffBootstrapHandler handler = CreateHandler( + staff, + contacts, + memberships, + admission); + + await Assert.ThrowsAsync(() => + handler.HandleAsync(integrationEvent, CancellationToken.None)); + + Assert.Empty(admission.TenantIds); + Assert.Empty(staff.Requests); + Assert.Equal(0, contacts.CallCount); + } + + private static OrganizationOwnerStaffBootstrapHandler CreateHandler( + IStaffIdentityBootstrapper staff, + IAuthMemberContactReader contacts, + IOrganizationMembershipInspector memberships, + IWorkspaceOperationalAdmissionPolicy admission) => new( + staff, + contacts, + memberships, + admission, + Options.Create(new BunkFyWorkspacesOptions + { + GlobalAuthScopeId = "global" + })); + private static OrganizationMembershipChangedIntegrationEvent CreateEvent( OrganizationMembershipRole role, OrganizationMembershipStatus status, - OrganizationMembershipChange change = OrganizationMembershipChange.Joined) + OrganizationMembershipChange change = OrganizationMembershipChange.Joined, + long membershipVersion = 1, + bool canonicalScope = true) { Guid organizationId = Guid.NewGuid(); return new OrganizationMembershipChangedIntegrationEvent( Guid.NewGuid(), new DateTimeOffset(2026, 7, 21, 8, 0, 0, TimeSpan.Zero), - organizationId.ToString("D"), + organizationId.ToString(canonicalScope ? "D" : "B"), organizationId, Guid.NewGuid(), Guid.NewGuid().ToString("D"), change, role, status, - 1); + membershipVersion); } + private static OrganizationMembershipSnapshot ActiveSnapshot( + OrganizationMembershipChangedIntegrationEvent integrationEvent, + long membershipVersion, + long scopeRevision) => new( + integrationEvent.OrganizationId, + integrationEvent.MembershipId, + OrganizationStatus.Active, + OrganizationScopeStatus.Open, + scopeRevision, + OrganizationMembershipRole.Owner, + OrganizationMembershipStatus.Active, + membershipVersion); + private sealed class RecordingStaffIdentityBootstrapper : IStaffIdentityBootstrapper { @@ -198,22 +378,32 @@ public Task BootstrapAsync( } } - private sealed class StubOrganizationAccessDecisionReader( - OrganizationAccessDecision decision = OrganizationAccessDecision.Allowed) - : IOrganizationAccessDecisionReader + private sealed class RecordingMembershipInspector( + Func resolve) + : IOrganizationMembershipInspector { - public int CallCount { get; private set; } + public List Calls { get; } = []; - public Task ReadAsync( + public Task FindAsync( Guid organizationId, + Guid membershipId, string subjectId, - CancellationToken cancellationToken) + CancellationToken cancellationToken = default) { - this.CallCount++; - return Task.FromResult(decision); + MembershipInspectionCall call = new( + organizationId, + membershipId, + subjectId); + this.Calls.Add(call); + return Task.FromResult(resolve(call)); } } + private sealed record MembershipInspectionCall( + Guid OrganizationId, + Guid MembershipId, + string SubjectId); + private sealed class RecordingContactReader : IAuthMemberContactReader { public int CallCount { get; private set; } diff --git a/src/Extensions/tests/BunkFy.Extensions.Workspaces.Tests/WorkspaceStaffOnboardingIdentityAnchorResolutionHandlerTests.cs b/src/Extensions/tests/BunkFy.Extensions.Workspaces.Tests/WorkspaceStaffOnboardingIdentityAnchorResolutionHandlerTests.cs new file mode 100644 index 00000000..8993cae7 --- /dev/null +++ b/src/Extensions/tests/BunkFy.Extensions.Workspaces.Tests/WorkspaceStaffOnboardingIdentityAnchorResolutionHandlerTests.cs @@ -0,0 +1,105 @@ +namespace BunkFy.Extensions.Workspaces.Tests; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Contracts; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class + WorkspaceStaffOnboardingIdentityAnchorResolutionHandlerTests +{ + [Theory] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus.Recorded)] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus.AlreadyRecorded)] + public async Task Exact_resolution_is_recorded_idempotently( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus status) + { + RecordingRecorder recorder = new(status); + WorkspaceStaffOnboardingIdentityAnchorResolutionHandler handler = + new(recorder); + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent + integrationEvent = CreateEvent(); + + await handler.HandleAsync(integrationEvent, CancellationToken.None); + + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest request = + Assert.Single(recorder.Requests); + Assert.Equal(integrationEvent.EventId, request.ResolutionEventId); + Assert.Equal(integrationEvent.ApplicationId, request.ApplicationId); + Assert.Equal(integrationEvent.StaffMemberId, request.StaffMemberId); + Assert.Equal( + integrationEvent.WorkspaceApplicationVersion, + request.WorkspaceApplicationVersion); + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + request.Disposition); + Assert.Equal(integrationEvent.OccurredAtUtc, request.ResolvedAtUtc); + } + + [Theory] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus.AnchorAbsent)] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus.Conflict)] + public async Task Non_recorded_resolution_keeps_the_inbox_retryable( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus status) + { + RecordingRecorder recorder = new(status); + WorkspaceStaffOnboardingIdentityAnchorResolutionHandler handler = + new(recorder); + + InvalidOperationException exception = await Assert.ThrowsAsync< + InvalidOperationException>(() => handler.HandleAsync( + CreateEvent(), + CancellationToken.None)); + + Assert.Contains(status.ToString(), exception.Message, StringComparison.Ordinal); + Assert.Single(recorder.Requests); + } + + private static + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent + CreateEvent() => new( + ResolutionEventId, + TenantId, + Now, + ApplicationId, + StaffMemberId, + workspaceApplicationVersion: 7, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted); + + private sealed class RecordingRecorder( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus status) + : IStaffWorkspaceOnboardingIdentityAnchorResolutionRecorder + { + public List + Requests + { get; } = []; + + public Task + RecordAsync( + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest request, + CancellationToken cancellationToken = default) + { + this.Requests.Add(request); + return Task.FromResult( + new StaffWorkspaceOnboardingIdentityAnchorResolutionResult( + status)); + } + } + + private static readonly Guid ApplicationId = + Guid.Parse("10000000-0000-0000-0000-000000000001"); + private static readonly Guid StaffMemberId = + Guid.Parse("20000000-0000-0000-0000-000000000002"); + private static readonly Guid ResolutionEventId = + Guid.Parse("20000000-0000-0000-0000-000000000003"); + private const string TenantId = + "30000000-0000-0000-0000-000000000003"; + private static readonly DateTimeOffset Now = + new(2026, 8, 11, 16, 0, 0, TimeSpan.Zero); +} 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/BunkFy.Modules.Staff.AdminApi/StaffAdminApiModule.cs b/src/Modules/Staff/BunkFy.Modules.Staff.AdminApi/StaffAdminApiModule.cs index faf8610f..bf725cb0 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.AdminApi/StaffAdminApiModule.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.AdminApi/StaffAdminApiModule.cs @@ -189,6 +189,8 @@ private static void MarkSensitiveResponse(HttpContext context) new(StaffApplicationErrors.CreationOperationConflict.Code, StatusCodes.Status409Conflict), new(StaffApplicationErrors.ProfileUpdateOperationConflict.Code, StatusCodes.Status409Conflict), new(StaffApplicationErrors.AuthSubjectOperationConflict.Code, StatusCodes.Status409Conflict), + new(StaffApplicationErrors.IdentityAnchorResolutionRequired.Code, StatusCodes.Status409Conflict), + new(StaffApplicationErrors.IdentityAnchorAccessClosureRequired.Code, StatusCodes.Status409Conflict), new(StaffApplicationErrors.LifecycleOperationConflict.Code, StatusCodes.Status409Conflict), new(StaffApplicationErrors.AssignmentOperationConflict.Code, StatusCodes.Status409Conflict), new(StaffApplicationErrors.AssignmentAlreadyExists.Code, StatusCodes.Status409Conflict), diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Api/StaffApiEndpointSupport.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Api/StaffApiEndpointSupport.cs index bb0c8209..1518b850 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Api/StaffApiEndpointSupport.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Api/StaffApiEndpointSupport.cs @@ -23,6 +23,8 @@ internal static class StaffApiEndpointSupport new(StaffApplicationErrors.CreationOperationConflict.Code, StatusCodes.Status409Conflict), new(StaffApplicationErrors.ProfileUpdateOperationConflict.Code, StatusCodes.Status409Conflict), new(StaffApplicationErrors.AuthSubjectOperationConflict.Code, StatusCodes.Status409Conflict), + new(StaffApplicationErrors.IdentityAnchorResolutionRequired.Code, StatusCodes.Status409Conflict), + new(StaffApplicationErrors.IdentityAnchorAccessClosureRequired.Code, StatusCodes.Status409Conflict), new(StaffApplicationErrors.LifecycleOperationConflict.Code, StatusCodes.Status409Conflict), new(StaffApplicationErrors.AssignmentOperationConflict.Code, StatusCodes.Status409Conflict), new(StaffApplicationErrors.AssignmentAlreadyExists.Code, StatusCodes.Status409Conflict), diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/ApplyStaffWorkspaceOnboardingAnchorsCommand.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/ApplyStaffWorkspaceOnboardingAnchorsCommand.cs new file mode 100644 index 00000000..83cfa931 --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/ApplyStaffWorkspaceOnboardingAnchorsCommand.cs @@ -0,0 +1,10 @@ +namespace BunkFy.Modules.Staff.Application.Commands; + +using BunkFy.Modules.Staff.Application.Ports; +using BunkFy.Modules.Staff.Contracts; +using Gma.Framework.Cqrs; + +public sealed record ApplyStaffIdentityProvisioningAnchorsCommand( + IReadOnlyList Candidates) + : ITransactionalCommand, + IStaffPersistenceRetryableCommand; diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/BootstrapStaffIdentityCommand.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/BootstrapStaffIdentityCommand.cs index 093a8ae8..dae806f3 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/BootstrapStaffIdentityCommand.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/BootstrapStaffIdentityCommand.cs @@ -5,6 +5,7 @@ namespace BunkFy.Modules.Staff.Application.Commands; public sealed record BootstrapStaffIdentityCommand( Guid OperationId, + Guid SourceId, string AuthSubjectId, string DisplayName, string? WorkEmail, diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/RecordStaffWorkspaceOnboardingIdentityAnchorResolutionCommand.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/RecordStaffWorkspaceOnboardingIdentityAnchorResolutionCommand.cs new file mode 100644 index 00000000..1374f567 --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/RecordStaffWorkspaceOnboardingIdentityAnchorResolutionCommand.cs @@ -0,0 +1,9 @@ +namespace BunkFy.Modules.Staff.Application.Commands; + +using BunkFy.Modules.Staff.Contracts; +using Gma.Framework.Cqrs; + +public sealed record RecordStaffWorkspaceOnboardingIdentityAnchorResolutionCommand( + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest Request) + : ITransactionalCommand< + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus>; diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/StaffIdentityProvisioningAnchorApplySummary.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/StaffIdentityProvisioningAnchorApplySummary.cs new file mode 100644 index 00000000..90ecc467 --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/StaffIdentityProvisioningAnchorApplySummary.cs @@ -0,0 +1,5 @@ +namespace BunkFy.Modules.Staff.Application.Commands; + +public sealed record StaffIdentityProvisioningAnchorApplySummary( + int AppliedCount, + int AlreadyAnchoredCount); diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/StaffRetentionMutationFailure.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/StaffRetentionMutationFailure.cs index 2731e7c0..26d450be 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/StaffRetentionMutationFailure.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Commands/StaffRetentionMutationFailure.cs @@ -7,5 +7,6 @@ internal enum StaffRetentionMutationFailure PolicyUnavailable = 2, PrerequisiteBlocked = 3, PrerequisiteUnavailable = 4, - MutationFailed = 5 + MutationFailed = 5, + IdentityAnchorResolutionRequired = 6 } diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Contributors/StaffRetentionContributor.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Contributors/StaffRetentionContributor.cs index f8f3ece4..7610903a 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Contributors/StaffRetentionContributor.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Contributors/StaffRetentionContributor.cs @@ -363,6 +363,10 @@ private static void RecordFailure( .PrerequisiteUnavailable: prerequisiteUnavailable = true; break; + case StaffRetentionMutationFailure + .IdentityAnchorResolutionRequired: + mutationFailed = true; + break; case StaffRetentionMutationFailure.MutationFailed: case StaffRetentionMutationFailure.None: default: diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/DependencyInjection.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/DependencyInjection.cs index f1297184..c5fa32f5 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/DependencyInjection.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/DependencyInjection.cs @@ -15,6 +15,7 @@ namespace BunkFy.Modules.Staff.Application; using Microsoft.Extensions.Options; using BunkFy.Modules.Properties.Contracts; using BunkFy.Modules.Staff.Application.Handlers; +using BunkFy.Modules.Staff.Application.Ports; using BunkFy.Modules.Staff.Application.Tasks; using BunkFy.Modules.Staff.Contracts; @@ -36,12 +37,28 @@ public static IServiceCollection AddStaffApplication(this IServiceCollection ser StaffRetentionOptionsValidator>()); services.AddApplicationServicesFromAssembly(typeof(DependencyInjection).Assembly); services.TryAddScoped(); + services.TryAddScoped< + IStaffIdentityProvisioningAnchorCutover, + StaffIdentityProvisioningAnchorCutover>(); + services.TryAddScoped< + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader, + StaffWorkspaceOnboardingIdentityAnchorLifecycle>(); + services.TryAddScoped< + IStaffWorkspaceOnboardingIdentityAnchorResolutionRecorder, + StaffWorkspaceOnboardingIdentityAnchorLifecycle>(); services.TryAddScoped(); services.TryAddScoped< IStaffPropertyAssignmentProvisioner, StaffPropertyAssignmentProvisioner>(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped< + IStaffIdentityProvisioningAnchorWriter, + StaffIdentityProvisioningAnchorWriter>(); + services.TryAddScoped< + StaffWorkspaceOnboardingAnchorCutoverCoordinator>(); + services.TryAddScoped< + StaffWorkspaceOnboardingIdentityAnchorLifecycleCoordinator>(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/ApplyStaffAnonymisationCommandHandler.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/ApplyStaffAnonymisationCommandHandler.cs index e4fe0414..ffe800e9 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/ApplyStaffAnonymisationCommandHandler.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/ApplyStaffAnonymisationCommandHandler.cs @@ -25,6 +25,7 @@ internal sealed class ApplyStaffAnonymisationCommandHandler( IStaffOperationLock operationLock, IStaffAnonymisationRepository anonymisation, IStaffMemberMutationOperationRepository memberMutationOperations, + IStaffIdentityProvisioningAnchorResolutionRepository resolutions, IDataRightsOperationApprovalGate approvalGate, IScopeContext scopeContext, ISystemClock clock, @@ -121,6 +122,14 @@ await operationLock.GetStaffMemberRevisionAsync( .AnonymisationOperationLockUnavailable); } + if (await resolutions.HasUnresolvedWorkspaceOnboardingAsync( + command.StaffMemberId, + cancellationToken).ConfigureAwait(false)) + { + return Result.Failure( + StaffApplicationErrors.IdentityAnchorResolutionRequired); + } + StaffMember? member = await members.GetForDataRightsAsync( command.StaffMemberId, cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/ApplyStaffIdentityProvisioningAnchorsCommandHandler.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/ApplyStaffIdentityProvisioningAnchorsCommandHandler.cs new file mode 100644 index 00000000..6a179c7d --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/ApplyStaffIdentityProvisioningAnchorsCommandHandler.cs @@ -0,0 +1,26 @@ +namespace BunkFy.Modules.Staff.Application.Handlers; + +using BunkFy.Modules.Staff.Application.Commands; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Scoping; + +internal sealed class ApplyStaffIdentityProvisioningAnchorsCommandHandler( + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator, + IScopeContext scopeContext) + : ICommandHandler +{ + public Task> HandleAsync( + ApplyStaffIdentityProvisioningAnchorsCommand command, + CancellationToken cancellationToken) => + !scopeContext.IsEnabled || + string.IsNullOrWhiteSpace(scopeContext.ScopeId) + ? Task.FromResult(Result.Failure< + StaffIdentityProvisioningAnchorApplySummary>( + StaffApplicationErrors.TenantRequired)) + : coordinator.ApplyAsync( + scopeContext.ScopeId, + command.Candidates, + cancellationToken); +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/ApplyStaffRetentionCommandHandler.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/ApplyStaffRetentionCommandHandler.cs index fc34b2d4..1f1e6a9c 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/ApplyStaffRetentionCommandHandler.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/ApplyStaffRetentionCommandHandler.cs @@ -21,6 +21,7 @@ internal sealed class ApplyStaffRetentionCommandHandler( IStaffMemberRepository members, IStaffOperationLock operationLock, IStaffMemberMutationOperationRepository memberMutationOperations, + IStaffIdentityProvisioningAnchorResolutionRepository resolutions, StaffRetentionEligibilityEvaluator eligibility, StaffRetentionPrerequisiteEvaluator prerequisites, IScopeContext scopeContext, @@ -115,6 +116,15 @@ await operationLock.GetStaffMemberRevisionAsync( .ProjectionUnavailable)); } + if (await resolutions.HasUnresolvedWorkspaceOnboardingAsync( + command.StaffMemberId, + cancellationToken).ConfigureAwait(false)) + { + return Result.Success(Failed( + StaffRetentionMutationFailure + .IdentityAnchorResolutionRequired)); + } + StaffRetentionCandidateSnapshot? snapshot = await candidates.LoadAsync( command.StaffMemberId, diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/BootstrapStaffIdentityCommandHandler.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/BootstrapStaffIdentityCommandHandler.cs index bac60196..29ac2020 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/BootstrapStaffIdentityCommandHandler.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/BootstrapStaffIdentityCommandHandler.cs @@ -13,7 +13,9 @@ namespace BunkFy.Modules.Staff.Application.Handlers; internal sealed class BootstrapStaffIdentityCommandHandler( IStaffMemberRepository members, + IStaffIdentityProvisioningAnchorRepository anchors, IStaffCreationOperationLock creationLock, + StaffMemberMutationCoordinator mutations, IScopeContext scopeContext, ISystemClock clock, IIdGenerator ids) : ICommandHandler @@ -28,12 +30,20 @@ public async Task> HandleAsync( return Result.Failure(StaffApplicationErrors.TenantRequired); } + string scopeId = scopeContext.ScopeId; + if (command.OperationId == Guid.Empty) { return Result.Failure( StaffApplicationErrors.CreationOperationInvalid); } + if (command.SourceId == Guid.Empty) + { + return Result.Failure( + StaffApplicationErrors.IdentityProvisioningSourceInvalid); + } + Result authSubject = StaffAuthSubject.Create( command.AuthSubjectId); if (authSubject.IsFailure || authSubject.Value.Value is null) @@ -44,29 +54,73 @@ public async Task> HandleAsync( string normalizedAuthSubject = authSubject.Value.Value; await creationLock.AcquireAsync( - scopeContext.ScopeId, - command.OperationId, + scopeId, + command.SourceId, cancellationToken) .ConfigureAwait(false); - StaffMember? operationOwner = await members.GetForSafetyTransitionAsync( + StaffIdentityProvisioningAnchorRecord? existingAnchor = + await anchors.GetAsync( + StaffIdentityProvisioningSourceKind.OrganizationMembership, + command.SourceId, + cancellationToken).ConfigureAwait(false); + if (existingAnchor is not null) + { + return await this.ValidateExistingAnchorAsync( + existingAnchor, + normalizedAuthSubject, + cancellationToken).ConfigureAwait(false); + } + + StaffMember? operationOwner = + await mutations.AcquireSafetyTransitionAsync( command.OperationId, cancellationToken).ConfigureAwait(false); if (operationOwner is not null) { - return string.Equals( - operationOwner.AuthSubjectId, - normalizedAuthSubject, - StringComparison.Ordinal) - ? Result.Success(Unit.Value) - : Result.Failure( + if (operationOwner.AuthSubjectId is not null && + !string.Equals( + operationOwner.AuthSubjectId, + normalizedAuthSubject, + StringComparison.Ordinal)) + { + return Result.Failure( StaffApplicationErrors.CreationOperationConflict); + } + + await this.AddAnchorAsync( + scopeId, + command.SourceId, + operationOwner.Id, + clock.UtcNow, + cancellationToken).ConfigureAwait(false); + return Result.Success(Unit.Value); } - if (await members.AuthSubjectExistsAsync( + StaffMember? subjectOwner = + await members.GetForSafetyTransitionByAuthSubjectAsync( normalizedAuthSubject, - exceptStaffMemberId: null, - cancellationToken).ConfigureAwait(false)) + cancellationToken).ConfigureAwait(false); + if (subjectOwner is not null) { + subjectOwner = await mutations.AcquireSafetyTransitionAsync( + subjectOwner.Id, + cancellationToken).ConfigureAwait(false); + if (subjectOwner is null || + !string.Equals( + subjectOwner.AuthSubjectId, + normalizedAuthSubject, + StringComparison.Ordinal)) + { + return Result.Failure( + StaffApplicationErrors.CreationOperationConflict); + } + + await this.AddAnchorAsync( + scopeId, + command.SourceId, + subjectOwner.Id, + clock.UtcNow, + cancellationToken).ConfigureAwait(false); return Result.Success(Unit.Value); } @@ -90,9 +144,10 @@ await creationLock.AcquireAsync( return Result.Failure(actor.Error); } + DateTimeOffset nowUtc = clock.UtcNow; Result created = StaffMember.Create( command.OperationId, - scopeContext.ScopeId, + scopeId, profile.Value.DisplayName, profile.Value.LegalName, profile.Value.WorkEmail, @@ -103,7 +158,7 @@ await creationLock.AcquireAsync( profile.Value.AuthSubjectId, actor.Value.Value, ids.NewId(), - clock.UtcNow); + nowUtc); if (created.IsFailure) { return Result.Failure(created.Error); @@ -111,6 +166,51 @@ await creationLock.AcquireAsync( await members.AddAsync(created.Value, cancellationToken) .ConfigureAwait(false); + await this.AddAnchorAsync( + scopeId, + command.SourceId, + created.Value.Id, + nowUtc, + cancellationToken).ConfigureAwait(false); return Result.Success(Unit.Value); } + + private async Task> ValidateExistingAnchorAsync( + StaffIdentityProvisioningAnchorRecord anchor, + string normalizedAuthSubject, + CancellationToken cancellationToken) + { + StaffMember? target = await mutations.AcquireSafetyTransitionAsync( + anchor.StaffMemberId, + cancellationToken).ConfigureAwait(false); + if (target is null) + { + return Result.Failure( + StaffApplicationErrors.IdentityProvisioningAnchorCorrupt); + } + + return target.AuthSubjectId is null || + string.Equals( + target.AuthSubjectId, + normalizedAuthSubject, + StringComparison.Ordinal) + ? Result.Success(Unit.Value) + : Result.Failure( + StaffApplicationErrors.IdentityProvisioningAnchorConflict); + } + + private Task AddAnchorAsync( + string scopeId, + Guid sourceId, + Guid staffMemberId, + DateTimeOffset anchoredAtUtc, + CancellationToken cancellationToken) => + anchors.AddAsync( + new StaffIdentityProvisioningAnchorRecord( + scopeId, + StaffIdentityProvisioningSourceKind.OrganizationMembership, + sourceId, + staffMemberId, + anchoredAtUtc), + cancellationToken); } diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/InspectStaffIdentityProvisioningAnchorsQueryHandler.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/InspectStaffIdentityProvisioningAnchorsQueryHandler.cs new file mode 100644 index 00000000..f91f3e3b --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/InspectStaffIdentityProvisioningAnchorsQueryHandler.cs @@ -0,0 +1,25 @@ +namespace BunkFy.Modules.Staff.Application.Handlers; + +using BunkFy.Modules.Staff.Application.Queries; +using BunkFy.Modules.Staff.Contracts; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Scoping; + +internal sealed class InspectStaffIdentityProvisioningAnchorsQueryHandler( + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator, + IScopeContext scopeContext) + : IQueryHandler> +{ + public Task>> HandleAsync( + InspectStaffIdentityProvisioningAnchorsQuery query, + CancellationToken cancellationToken) => + !scopeContext.IsEnabled || + string.IsNullOrWhiteSpace(scopeContext.ScopeId) + ? Task.FromResult(Result.Failure>( + StaffApplicationErrors.TenantRequired)) + : coordinator.InspectAsync(query.Candidates, cancellationToken); +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/ReadStaffWorkspaceOnboardingIdentityAnchorOutcomesQueryHandler.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/ReadStaffWorkspaceOnboardingIdentityAnchorOutcomesQueryHandler.cs new file mode 100644 index 00000000..afc02f69 --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/ReadStaffWorkspaceOnboardingIdentityAnchorOutcomesQueryHandler.cs @@ -0,0 +1,28 @@ +namespace BunkFy.Modules.Staff.Application.Handlers; + +using BunkFy.Modules.Staff.Application.Queries; +using BunkFy.Modules.Staff.Contracts; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Scoping; + +internal sealed class ReadStaffWorkspaceOnboardingIdentityAnchorOutcomesQueryHandler( + StaffWorkspaceOnboardingIdentityAnchorLifecycleCoordinator coordinator, + IScopeContext scopeContext) + : IQueryHandler< + ReadStaffWorkspaceOnboardingIdentityAnchorOutcomesQuery, + IReadOnlyList> +{ + public Task>> HandleAsync( + ReadStaffWorkspaceOnboardingIdentityAnchorOutcomesQuery query, + CancellationToken cancellationToken) => + !scopeContext.IsEnabled || + string.IsNullOrWhiteSpace(scopeContext.ScopeId) + ? Task.FromResult(Result.Failure>( + StaffApplicationErrors.TenantRequired)) + : coordinator.ReadAsync( + query.Requests, + cancellationToken); +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/RecordStaffWorkspaceOnboardingIdentityAnchorResolutionCommandHandler.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/RecordStaffWorkspaceOnboardingIdentityAnchorResolutionCommandHandler.cs new file mode 100644 index 00000000..db386390 --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/RecordStaffWorkspaceOnboardingIdentityAnchorResolutionCommandHandler.cs @@ -0,0 +1,30 @@ +namespace BunkFy.Modules.Staff.Application.Handlers; + +using BunkFy.Modules.Staff.Application.Commands; +using BunkFy.Modules.Staff.Contracts; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Scoping; + +internal sealed class RecordStaffWorkspaceOnboardingIdentityAnchorResolutionCommandHandler( + StaffWorkspaceOnboardingIdentityAnchorLifecycleCoordinator coordinator, + IScopeContext scopeContext) + : ICommandHandler< + RecordStaffWorkspaceOnboardingIdentityAnchorResolutionCommand, + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus> +{ + public Task> HandleAsync( + RecordStaffWorkspaceOnboardingIdentityAnchorResolutionCommand + command, + CancellationToken cancellationToken) => + !scopeContext.IsEnabled || + string.IsNullOrWhiteSpace(scopeContext.ScopeId) + ? Task.FromResult(Result.Failure< + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus>( + StaffApplicationErrors.TenantRequired)) + : coordinator.RecordAsync( + scopeContext.ScopeId, + command.Request, + cancellationToken); +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffAuthSubjectChangeCoordinator.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffAuthSubjectChangeCoordinator.cs index 2eedcd65..384ec54f 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffAuthSubjectChangeCoordinator.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffAuthSubjectChangeCoordinator.cs @@ -11,6 +11,7 @@ namespace BunkFy.Modules.Staff.Application.Handlers; internal sealed class StaffAuthSubjectChangeCoordinator( IStaffMemberRepository members, IStaffMemberMutationOperationRepository operations, + IStaffIdentityProvisioningAnchorRepository anchors, ISystemClock clock, IIdGenerator ids) { @@ -55,6 +56,15 @@ await operations.GetAsync( if (transition.Value) { + if (await anchors.HasWorkspaceOnboardingAsync( + member.Id, + cancellationToken).ConfigureAwait(false)) + { + return Result.Failure( + StaffApplicationErrors + .IdentityAnchorAccessClosureRequired); + } + Result uniqueness = await StaffMemberUniqueness.EnsureAuthSubjectAsync( members, values.AuthSubject.Value, diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffIdentityBootstrapper.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffIdentityBootstrapper.cs index 10167324..f86c60a0 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffIdentityBootstrapper.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffIdentityBootstrapper.cs @@ -17,6 +17,7 @@ public async Task BootstrapAsync( Result result = await dispatcher.SendAsync( new BootstrapStaffIdentityCommand( request.OperationId, + request.SourceId, request.AuthSubjectId, request.DisplayName, request.WorkEmail, diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffIdentityProvisioningAnchorWriter.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffIdentityProvisioningAnchorWriter.cs new file mode 100644 index 00000000..1515f17d --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffIdentityProvisioningAnchorWriter.cs @@ -0,0 +1,119 @@ +namespace BunkFy.Modules.Staff.Application.Handlers; + +using BunkFy.Modules.Staff.Application.Ports; +using BunkFy.Modules.Staff.Contracts; +using Gma.Framework.Messaging; +using Gma.Framework.Runtime.Identity; + +internal sealed class StaffIdentityProvisioningAnchorWriter( + IStaffIdentityProvisioningAnchorRepository anchors, + IOutboxWriterRegistry outboxWriters, + IIdGenerator ids) + : IStaffIdentityProvisioningAnchorWriter +{ + public async Task AddAsync( + StaffIdentityProvisioningAnchorRecord anchor, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(anchor); + if (anchor.ResolutionEventId.HasValue) + { + throw new InvalidOperationException( + "Callers cannot supply an identity-anchor resolution event coordinate."); + } + + StaffIdentityProvisioningAnchorRecord? existing = + await anchors.GetAsync( + anchor.SourceKind, + anchor.SourceId, + cancellationToken).ConfigureAwait(false); + if (existing is not null) + { + return Matches(existing, anchor) + ? existing + : throw new InvalidOperationException( + "The identity-provisioning anchor coordinate is already owned by another target."); + } + + StaffIdentityProvisioningAnchorRecord persisted = + this.NormalizeForInsert(anchor); + await anchors.AddAsync(persisted, cancellationToken) + .ConfigureAwait(false); + if (persisted.SourceKind != + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding) + { + return persisted; + } + + await outboxWriters.GetRequired(StaffModuleMetadata.Name) + .EnqueueAsync( + new StaffIdentityProvisioningAnchorCreatedIntegrationEvent( + persisted.SourceId, + persisted.ScopeId, + persisted.AnchoredAtUtc, + persisted.SourceId, + persisted.StaffMemberId, + persisted.ResolutionEventId!.Value), + cancellationToken).ConfigureAwait(false); + return persisted; + } + + private StaffIdentityProvisioningAnchorRecord NormalizeForInsert( + StaffIdentityProvisioningAnchorRecord anchor) + { + if (anchor.SourceKind == + StaffIdentityProvisioningSourceKind.OrganizationMembership) + { + return anchor.ResolutionEventId is null + ? anchor + : throw new InvalidOperationException( + "Organization-membership anchors cannot own a Workspace resolution event."); + } + + if (anchor.SourceKind != + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding) + { + throw new InvalidOperationException( + "The identity-provisioning anchor source kind is invalid."); + } + + Guid resolutionEventId = this.GenerateResolutionEventId(anchor.SourceId); + if (resolutionEventId == Guid.Empty || + resolutionEventId == anchor.SourceId) + { + throw new InvalidOperationException( + "The Workspace resolution event coordinate is invalid."); + } + + return anchor with { ResolutionEventId = resolutionEventId }; + } + + private Guid GenerateResolutionEventId(Guid sourceId) + { + for (int attempt = 0; attempt < 8; attempt++) + { + Guid candidate = ids.NewId(); + if (candidate != Guid.Empty && candidate != sourceId) + { + return candidate; + } + } + + throw new InvalidOperationException( + "A distinct Workspace resolution event coordinate could not be generated."); + } + + private static bool Matches( + StaffIdentityProvisioningAnchorRecord existing, + StaffIdentityProvisioningAnchorRecord requested) => + string.Equals( + existing.ScopeId, + requested.ScopeId, + StringComparison.Ordinal) && + existing.SourceKind == requested.SourceKind && + existing.SourceId == requested.SourceId && + existing.StaffMemberId == requested.StaffMemberId && + existing.ResolutionEventId.HasValue == + (existing.SourceKind == + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding); +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffLifecycleChangeCoordinator.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffLifecycleChangeCoordinator.cs index df8aa2f9..427ba718 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffLifecycleChangeCoordinator.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffLifecycleChangeCoordinator.cs @@ -10,6 +10,7 @@ namespace BunkFy.Modules.Staff.Application.Handlers; internal sealed class StaffLifecycleChangeCoordinator( IStaffMemberMutationOperationRepository operations, + IStaffIdentityProvisioningAnchorResolutionRepository resolutions, StaffLifecyclePolicyEvaluator policies, ISystemClock clock, IIdGenerator ids) @@ -125,6 +126,15 @@ await operations.GetAsync( StaffApplicationErrors.LifecycleOperationConflict); } + if (targetStatus == StaffStatus.Active && + await resolutions.HasUnresolvedWorkspaceOnboardingAsync( + member.Id, + cancellationToken).ConfigureAwait(false)) + { + return Result.Failure( + StaffApplicationErrors.IdentityAnchorResolutionRequired); + } + DateTimeOffset nowUtc = StaffMutationTime.Normalize(clock.UtcNow); DateOnly effectiveOn = requestedEffectiveOn ?? DateOnly.FromDateTime(nowUtc.UtcDateTime); diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffMemberMutationCoordinator.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffMemberMutationCoordinator.cs index 1b569623..107fff03 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffMemberMutationCoordinator.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffMemberMutationCoordinator.cs @@ -13,14 +13,14 @@ internal sealed class StaffMemberMutationCoordinator( Guid staffMemberId, CancellationToken cancellationToken) => this.AcquireAndReloadAsync( staffMemberId, - members.GetAsync, + members.ReloadOperationalAsync, cancellationToken); public Task AcquireSafetyTransitionAsync( Guid staffMemberId, CancellationToken cancellationToken) => this.AcquireAndReloadAsync( staffMemberId, - members.GetForSafetyTransitionAsync, + members.ReloadForSafetyTransitionAsync, cancellationToken); private async Task AcquireAndReloadAsync( diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffOnboardingProvisioner.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffOnboardingProvisioner.cs index f916eaa8..e5f83563 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffOnboardingProvisioner.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffOnboardingProvisioner.cs @@ -1,11 +1,14 @@ namespace BunkFy.Modules.Staff.Application.Handlers; using BunkFy.Modules.Staff.Application.Commands; +using BunkFy.Modules.Staff.Application.Ports; using BunkFy.Modules.Staff.Contracts; using Gma.Framework.Cqrs; using Gma.Framework.Results; -internal sealed class StaffOnboardingProvisioner(IRequestDispatcher dispatcher) +internal sealed class StaffOnboardingProvisioner( + IRequestDispatcher dispatcher, + IStaffIdentityProvisioningAnchorRepository anchors) : IStaffOnboardingProvisioner { public async Task ProvisionAsync( @@ -27,8 +30,32 @@ public async Task ProvisionAsync( request.ActorId), cancellationToken).ConfigureAwait(false); - return result.IsSuccess - ? new StaffOnboardingProvisioningResult(true, result.Value.StaffMemberId, null) - : new StaffOnboardingProvisioningResult(false, null, result.Error.Code); + if (result.IsFailure) + { + return new StaffOnboardingProvisioningResult( + false, + null, + result.Error.Code); + } + + StaffIdentityProvisioningAnchorRecord? anchor = await anchors.GetAsync( + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + request.OperationId, + cancellationToken).ConfigureAwait(false); + if (anchor is null || + anchor.StaffMemberId != result.Value.StaffMemberId || + !anchor.ResolutionEventId.HasValue) + { + return new StaffOnboardingProvisioningResult( + false, + null, + StaffApplicationErrors.OnboardingReplayUnavailable.Code); + } + + return new StaffOnboardingProvisioningResult( + true, + result.Value.StaffMemberId, + null, + anchor.ResolutionEventId.Value); } } diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffOnboardingProvisioningCoordinator.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffOnboardingProvisioningCoordinator.cs index 67e8f7a9..d934c5e4 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffOnboardingProvisioningCoordinator.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffOnboardingProvisioningCoordinator.cs @@ -12,6 +12,8 @@ namespace BunkFy.Modules.Staff.Application.Handlers; internal sealed class StaffOnboardingProvisioningCoordinator( IStaffMemberRepository members, + IStaffIdentityProvisioningAnchorRepository anchors, + IStaffIdentityProvisioningAnchorWriter anchorWriter, IStaffOnboardingProvisioningOperationRepository operations, IStaffCreationOperationLock operationLock, StaffMemberMutationCoordinator mutations, @@ -62,12 +64,27 @@ await operationLock.AcquireAsync( command.OperationId, cancellationToken) .ConfigureAwait(false); + StaffIdentityProvisioningAnchorRecord? existingAnchor = + await anchors.GetAsync( + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + command.OperationId, + cancellationToken).ConfigureAwait(false); StaffMemberMutationOperationRecord? existingOperation = await operations.GetAsync( command.OperationId, cancellationToken).ConfigureAwait(false); - if (existingOperation is not null) + if (existingAnchor is not null || existingOperation is not null) { + if (existingAnchor is null || + existingOperation is null || + !existingAnchor.ResolutionEventId.HasValue || + existingAnchor.StaffMemberId != + existingOperation.StaffMemberId) + { + return Result.Failure( + StaffApplicationErrors.OnboardingReplayUnavailable); + } + return await this.ReplayAsync( existingOperation, profile.Value, @@ -131,6 +148,14 @@ await operations.AddAsync( mutation.Member.Version, nowUtc), cancellationToken).ConfigureAwait(false); + await anchorWriter.AddAsync( + new StaffIdentityProvisioningAnchorRecord( + mutation.Member.ScopeId, + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + command.OperationId, + mutation.Member.Id, + nowUtc), + cancellationToken).ConfigureAwait(false); return Result.Success(mutation.Member.ToDto()); } diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffWorkspaceOnboardingAnchorCutover.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffWorkspaceOnboardingAnchorCutover.cs new file mode 100644 index 00000000..3c1212ed --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffWorkspaceOnboardingAnchorCutover.cs @@ -0,0 +1,44 @@ +namespace BunkFy.Modules.Staff.Application.Handlers; + +using BunkFy.Modules.Staff.Application.Commands; +using BunkFy.Modules.Staff.Application.Queries; +using BunkFy.Modules.Staff.Contracts; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; + +internal sealed class StaffIdentityProvisioningAnchorCutover( + IRequestDispatcher dispatcher) + : IStaffIdentityProvisioningAnchorCutover +{ + public async Task InspectAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(candidates); + Result> + result = await dispatcher.QueryAsync( + new InspectStaffIdentityProvisioningAnchorsQuery(candidates), + cancellationToken).ConfigureAwait(false); + return result.IsSuccess + ? new(true, result.Value, null) + : new(false, [], result.Error.Code); + } + + public async Task ApplyAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(candidates); + Result result = + await dispatcher.SendAsync( + new ApplyStaffIdentityProvisioningAnchorsCommand(candidates), + cancellationToken).ConfigureAwait(false); + return result.IsSuccess + ? new( + true, + result.Value.AppliedCount, + result.Value.AlreadyAnchoredCount, + null) + : new(false, 0, 0, result.Error.Code); + } +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffWorkspaceOnboardingAnchorCutoverCoordinator.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffWorkspaceOnboardingAnchorCutoverCoordinator.cs new file mode 100644 index 00000000..a6b23181 --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffWorkspaceOnboardingAnchorCutoverCoordinator.cs @@ -0,0 +1,387 @@ +namespace BunkFy.Modules.Staff.Application.Handlers; + +using BunkFy.Modules.Staff.Application.Commands; +using BunkFy.Modules.Staff.Application.Ports; +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Staff.Domain.ValueObjects; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Time; + +internal sealed class StaffWorkspaceOnboardingAnchorCutoverCoordinator( + IStaffIdentityProvisioningAnchorRepository anchors, + IStaffIdentityProvisioningAnchorWriter anchorWriter, + IStaffOnboardingProvisioningOperationRepository operations, + IStaffMemberRepository members, + IStaffCreationOperationLock creationLock, + StaffMemberMutationCoordinator mutations, + ISystemClock clock) +{ + public async Task>> InspectAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken) + { + Result validated = + Validate(candidates, requireSeedableOwnerTarget: false); + if (validated.IsFailure) + { + return Result.Failure>( + validated.Error); + } + + CutoverEvidence evidence = await this.LoadEvidenceAsync( + validated.Value, + cancellationToken).ConfigureAwait(false); + StaffIdentityProvisioningAnchorCandidateInspection[] results = + validated.Value.Select(candidate => InspectOne( + candidate, + evidence)).ToArray(); + + return Result.Success>(results); + } + + public async Task> + ApplyAsync( + string scopeId, + IReadOnlyList candidates, + CancellationToken cancellationToken) + { + Result validated = + Validate(candidates, requireSeedableOwnerTarget: true); + if (validated.IsFailure) + { + return Result.Failure( + validated.Error); + } + + foreach (Guid sourceId in validated.Value + .Select(candidate => candidate.SourceId) + .Distinct() + .Order()) + { + await creationLock.AcquireAsync( + scopeId, + sourceId, + cancellationToken).ConfigureAwait(false); + } + + CutoverEvidence beforeTargetLocks = await this.LoadEvidenceAsync( + validated.Value, + cancellationToken).ConfigureAwait(false); + + foreach (Guid targetId in beforeTargetLocks.TargetIds.Order()) + { + if (await mutations.AcquireSafetyTransitionAsync( + targetId, + cancellationToken).ConfigureAwait(false) is null) + { + return Result.Failure< + StaffIdentityProvisioningAnchorApplySummary>( + StaffApplicationErrors.IdentityAnchorCutoverBlocked); + } + } + + CutoverEvidence lockedEvidence = await this.LoadEvidenceAsync( + validated.Value, + cancellationToken).ConfigureAwait(false); + (StaffIdentityProvisioningAnchorCandidate Candidate, + StaffIdentityProvisioningAnchorCandidateInspection Inspection)[] + inspected = validated.Value.Select(candidate => ( + candidate, + InspectOne(candidate, lockedEvidence))).ToArray(); + + if (inspected.Any(item => item.Inspection.Disposition is + StaffIdentityProvisioningAnchorCutoverDisposition.Ambiguous or + StaffIdentityProvisioningAnchorCutoverDisposition.Conflict)) + { + return Result.Failure( + StaffApplicationErrors.IdentityAnchorCutoverBlocked); + } + + int applied = 0; + int already = 0; + DateTimeOffset nowUtc = clock.UtcNow; + foreach ((StaffIdentityProvisioningAnchorCandidate candidate, + StaffIdentityProvisioningAnchorCandidateInspection inspection) in + inspected) + { + if (inspection.Disposition == + StaffIdentityProvisioningAnchorCutoverDisposition.AlreadyAnchored) + { + already++; + continue; + } + + lockedEvidence.Receipts.TryGetValue( + candidate.SourceId, + out StaffMemberMutationOperationRecord? receipt); + Guid staffMemberId = candidate.StaffMemberId!.Value; + DateTimeOffset anchoredAtUtc = receipt?.CompletedAtUtc ?? nowUtc; + await anchorWriter.AddAsync( + new StaffIdentityProvisioningAnchorRecord( + scopeId, + ToPersistenceKind(candidate.SourceKind), + candidate.SourceId, + staffMemberId, + anchoredAtUtc), + cancellationToken).ConfigureAwait(false); + applied++; + } + + return Result.Success( + new StaffIdentityProvisioningAnchorApplySummary(applied, already)); + } + + private async Task LoadEvidenceAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken) + { + StaffIdentityProvisioningSourceKey[] sourceKeys = candidates + .Select(candidate => new StaffIdentityProvisioningSourceKey( + ToPersistenceKind(candidate.SourceKind), + candidate.SourceId)) + .ToArray(); + IReadOnlyList anchorRecords = + await anchors.ListAsync(sourceKeys, cancellationToken) + .ConfigureAwait(false); + Guid[] workspaceSourceIds = candidates + .Where(candidate => candidate.SourceKind == + StaffIdentityProvisioningAnchorSourceKind.WorkspaceOnboarding) + .Select(candidate => candidate.SourceId) + .ToArray(); + IReadOnlyList receiptRecords = + await operations.ListAsync( + workspaceSourceIds, + cancellationToken).ConfigureAwait(false); + Dictionary<(StaffIdentityProvisioningSourceKind, Guid), + StaffIdentityProvisioningAnchorRecord> anchorsBySource = + anchorRecords.ToDictionary( + anchor => (anchor.SourceKind, anchor.SourceId)); + Dictionary receiptsBySource = + receiptRecords.ToDictionary(receipt => receipt.OperationId); + HashSet targetIds = candidates + .Where(candidate => candidate.StaffMemberId.HasValue) + .Select(candidate => candidate.StaffMemberId!.Value) + .Concat(anchorRecords.Select(anchor => anchor.StaffMemberId)) + .Concat(receiptRecords.Select(receipt => receipt.StaffMemberId)) + .ToHashSet(); + IReadOnlyList targetRecords = + await members.ListSafetyEvidenceAsync( + targetIds.Order().ToArray(), + cancellationToken).ConfigureAwait(false); + return new( + anchorsBySource, + receiptsBySource, + targetRecords.ToDictionary(target => target.StaffMemberId), + targetIds); + } + + private static StaffIdentityProvisioningAnchorCandidateInspection + InspectOne( + StaffIdentityProvisioningAnchorCandidate candidate, + CutoverEvidence evidence) + { + evidence.Anchors.TryGetValue( + (ToPersistenceKind(candidate.SourceKind), candidate.SourceId), + out StaffIdentityProvisioningAnchorRecord? anchor); + StaffMemberMutationOperationRecord? receipt = null; + if (candidate.SourceKind == + StaffIdentityProvisioningAnchorSourceKind.WorkspaceOnboarding) + { + evidence.Receipts.TryGetValue( + candidate.SourceId, + out receipt); + } + + Guid? resolvedTarget = anchor?.StaffMemberId ?? + candidate.StaffMemberId ?? receipt?.StaffMemberId; + StaffMemberSafetyEvidence? target = resolvedTarget.HasValue && + evidence.Targets.TryGetValue( + resolvedTarget.Value, + out StaffMemberSafetyEvidence? loadedTarget) + ? loadedTarget + : null; + + if (resolvedTarget.HasValue && target is null) + { + return Inspection( + candidate, + StaffIdentityProvisioningAnchorCutoverDisposition.Conflict); + } + + if (anchor is not null) + { + bool conflicts = + (candidate.StaffMemberId.HasValue && + candidate.StaffMemberId.Value != anchor.StaffMemberId) || + (receipt is not null && + receipt.StaffMemberId != anchor.StaffMemberId) || + (candidate.SourceKind == + StaffIdentityProvisioningAnchorSourceKind + .WorkspaceOnboarding && + ((!candidate.StaffMemberId.HasValue && receipt is null) || + !string.Equals( + target!.AuthSubjectId, + candidate.ExpectedAuthSubjectId, + StringComparison.Ordinal))) || + (candidate.SourceKind == + StaffIdentityProvisioningAnchorSourceKind + .OrganizationMembership && + target!.AuthSubjectId is not null && + !string.Equals( + target.AuthSubjectId, + candidate.ExpectedAuthSubjectId, + StringComparison.Ordinal)); + return Inspection( + candidate, + conflicts + ? StaffIdentityProvisioningAnchorCutoverDisposition.Conflict + : StaffIdentityProvisioningAnchorCutoverDisposition.AlreadyAnchored); + } + + if (!candidate.StaffMemberId.HasValue) + { + return Inspection( + candidate, + receipt is null + ? StaffIdentityProvisioningAnchorCutoverDisposition + .Ambiguous + : StaffIdentityProvisioningAnchorCutoverDisposition + .Conflict); + } + + if (candidate.SourceKind == + StaffIdentityProvisioningAnchorSourceKind.OrganizationMembership) + { + bool subjectMatches = target!.AuthSubjectId is null + ? candidate.ReviewedErasedTarget + : !candidate.ReviewedErasedTarget && + string.Equals( + target.AuthSubjectId, + candidate.ExpectedAuthSubjectId, + StringComparison.Ordinal); + return Inspection( + candidate, + subjectMatches + ? StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromReviewedOwnerMap + : StaffIdentityProvisioningAnchorCutoverDisposition + .Conflict); + } + + if (candidate.StaffMemberId.HasValue) + { + bool subjectMatches = string.Equals( + target!.AuthSubjectId, + candidate.ExpectedAuthSubjectId, + StringComparison.Ordinal); + return Inspection( + candidate, + !subjectMatches || + (receipt is not null && + receipt.StaffMemberId != candidate.StaffMemberId.Value) + ? StaffIdentityProvisioningAnchorCutoverDisposition.Conflict + : StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromWorkspace); + } + + throw new InvalidOperationException( + "The Staff identity-anchor cutover candidate is invalid."); + } + + private static Result Validate( + IReadOnlyList? candidates, + bool requireSeedableOwnerTarget) + { + if (candidates is null || candidates.Count is < 1 or + > StaffWorkspaceOnboardingAnchorCutoverLimits.MaximumBatchSize) + { + return Result.Failure( + StaffApplicationErrors.IdentityAnchorCutoverRequestInvalid); + } + + List normalized = + new(candidates.Count); + foreach (StaffIdentityProvisioningAnchorCandidate candidate in + candidates) + { + Result subject = StaffAuthSubject.Create( + candidate.ExpectedAuthSubjectId); + if (subject.IsFailure || subject.Value.Value is null) + { + return Result.Failure< + StaffIdentityProvisioningAnchorCandidate[]>( + StaffApplicationErrors + .IdentityAnchorCutoverRequestInvalid); + } + + normalized.Add(candidate with + { + ExpectedAuthSubjectId = subject.Value.Value + }); + } + + StaffIdentityProvisioningAnchorCandidate[] ordered = normalized + .OrderBy(candidate => candidate.SourceKind) + .ThenBy(candidate => candidate.SourceId) + .ToArray(); + if (ordered.Any(candidate => + candidate.SourceKind is not ( + StaffIdentityProvisioningAnchorSourceKind + .WorkspaceOnboarding or + StaffIdentityProvisioningAnchorSourceKind + .OrganizationMembership) || + candidate.SourceId == Guid.Empty || + candidate.StaffMemberId == Guid.Empty || + (candidate.SourceKind == + StaffIdentityProvisioningAnchorSourceKind + .WorkspaceOnboarding && + (string.IsNullOrWhiteSpace( + candidate.ExpectedAuthSubjectId) || + candidate.ReviewedErasedTarget)) || + (candidate.SourceKind == + StaffIdentityProvisioningAnchorSourceKind + .OrganizationMembership && + (string.IsNullOrWhiteSpace( + candidate.ExpectedAuthSubjectId) || + (requireSeedableOwnerTarget && + !candidate.StaffMemberId.HasValue) || + (!candidate.StaffMemberId.HasValue && + candidate.ReviewedErasedTarget)))) || + ordered.Select(candidate => + (candidate.SourceKind, candidate.SourceId)) + .Distinct() + .Count() != ordered.Length) + { + return Result.Failure( + StaffApplicationErrors.IdentityAnchorCutoverRequestInvalid); + } + + return Result.Success(ordered); + } + + private static StaffIdentityProvisioningAnchorCandidateInspection Inspection( + StaffIdentityProvisioningAnchorCandidate candidate, + StaffIdentityProvisioningAnchorCutoverDisposition disposition) => + new(candidate.SourceKind, candidate.SourceId, disposition); + + private static StaffIdentityProvisioningSourceKind ToPersistenceKind( + StaffIdentityProvisioningAnchorSourceKind sourceKind) => + sourceKind switch + { + StaffIdentityProvisioningAnchorSourceKind.WorkspaceOnboarding => + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + StaffIdentityProvisioningAnchorSourceKind.OrganizationMembership => + StaffIdentityProvisioningSourceKind.OrganizationMembership, + _ => StaffIdentityProvisioningSourceKind.Unknown + }; + + private sealed record CutoverEvidence( + IReadOnlyDictionary<(StaffIdentityProvisioningSourceKind, Guid), + StaffIdentityProvisioningAnchorRecord> Anchors, + IReadOnlyDictionary Receipts, + IReadOnlyDictionary Targets, + IReadOnlySet TargetIds); +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffWorkspaceOnboardingIdentityAnchorLifecycle.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffWorkspaceOnboardingIdentityAnchorLifecycle.cs new file mode 100644 index 00000000..310e7724 --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffWorkspaceOnboardingIdentityAnchorLifecycle.cs @@ -0,0 +1,47 @@ +namespace BunkFy.Modules.Staff.Application.Handlers; + +using BunkFy.Modules.Staff.Application.Commands; +using BunkFy.Modules.Staff.Application.Queries; +using BunkFy.Modules.Staff.Contracts; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; + +internal sealed class StaffWorkspaceOnboardingIdentityAnchorLifecycle( + IRequestDispatcher dispatcher) + : IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader, + IStaffWorkspaceOnboardingIdentityAnchorResolutionRecorder +{ + public async Task> ReadAsync( + IReadOnlyList + requests, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(requests); + Result> + result = await dispatcher.QueryAsync( + new ReadStaffWorkspaceOnboardingIdentityAnchorOutcomesQuery( + requests), + cancellationToken).ConfigureAwait(false); + return result.IsSuccess + ? result.Value + : throw new InvalidOperationException(result.Error.Code); + } + + public async Task + RecordAsync( + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + Result result = + await dispatcher.SendAsync( + new + RecordStaffWorkspaceOnboardingIdentityAnchorResolutionCommand( + request), + cancellationToken).ConfigureAwait(false); + return result.IsSuccess + ? new(result.Value) + : throw new InvalidOperationException(result.Error.Code); + } +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffWorkspaceOnboardingIdentityAnchorLifecycleCoordinator.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffWorkspaceOnboardingIdentityAnchorLifecycleCoordinator.cs new file mode 100644 index 00000000..dbf3f99f --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Handlers/StaffWorkspaceOnboardingIdentityAnchorLifecycleCoordinator.cs @@ -0,0 +1,326 @@ +namespace BunkFy.Modules.Staff.Application.Handlers; + +using BunkFy.Modules.Staff.Application.Ports; +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Staff.Domain.Aggregates; +using BunkFy.Modules.Staff.Domain.ValueObjects; +using Gma.Framework.Results; + +internal sealed class StaffWorkspaceOnboardingIdentityAnchorLifecycleCoordinator( + IStaffIdentityProvisioningAnchorRepository anchors, + IStaffIdentityProvisioningAnchorResolutionRepository resolutions, + IStaffMemberRepository members, + IStaffCreationOperationLock creationLock) +{ + public async Task>> ReadAsync( + IReadOnlyList + requests, + CancellationToken cancellationToken) + { + if (requests is null || requests.Count is < 1 or + > StaffWorkspaceOnboardingIdentityAnchorLifecycleLimits + .MaximumBatchSize || + requests.Any(request => !IsValid(request)) || + requests.Select(request => request.ApplicationId) + .Distinct().Count() != requests.Count) + { + return Result.Failure>( + StaffApplicationErrors + .IdentityAnchorLifecycleRequestInvalid); + } + + StaffIdentityProvisioningSourceKey[] sourceKeys = requests + .Select(request => new StaffIdentityProvisioningSourceKey( + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + request.ApplicationId)) + .ToArray(); + IReadOnlyList loadedAnchors = + await anchors.ListAsync(sourceKeys, cancellationToken) + .ConfigureAwait(false); + IReadOnlyList + loadedResolutions = await resolutions.ListAsync( + requests.Select(request => request.ApplicationId).ToArray(), + cancellationToken).ConfigureAwait(false); + IReadOnlyList loadedTargets = + await members.ListSafetyEvidenceAsync( + loadedAnchors.Select(anchor => anchor.StaffMemberId) + .Distinct() + .Order() + .ToArray(), + cancellationToken).ConfigureAwait(false); + Dictionary + anchorsBySource = loadedAnchors.ToDictionary( + anchor => anchor.SourceId); + Dictionary + resolutionsBySource = loadedResolutions.ToDictionary( + resolution => resolution.SourceId); + Dictionary targetsById = + loadedTargets.ToDictionary(target => target.StaffMemberId); + + StaffWorkspaceOnboardingIdentityAnchorOutcome[] outcomes = + requests.Select(request => Outcome( + request, + anchorsBySource, + resolutionsBySource, + targetsById)).ToArray(); + return Result.Success>(outcomes); + } + + public async Task> RecordAsync( + string scopeId, + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest request, + CancellationToken cancellationToken) + { + if (!IsValid(request)) + { + return Result.Failure< + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus>( + StaffApplicationErrors + .IdentityAnchorLifecycleRequestInvalid); + } + + await creationLock.AcquireAsync( + scopeId, + request.ApplicationId, + cancellationToken).ConfigureAwait(false); + StaffIdentityProvisioningAnchorRecord? anchor = await anchors.GetAsync( + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + request.ApplicationId, + cancellationToken).ConfigureAwait(false); + if (anchor is null) + { + return Result.Success( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus + .AnchorAbsent); + } + + if (!string.Equals( + anchor.ScopeId, + scopeId, + StringComparison.Ordinal) || + anchor.StaffMemberId != request.StaffMemberId || + !anchor.ResolutionEventId.HasValue || + anchor.ResolutionEventId.Value == Guid.Empty || + anchor.ResolutionEventId.Value != request.ResolutionEventId) + { + return Result.Success( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus + .Conflict); + } + + StaffIdentityProvisioningAnchorResolutionRecord? existing = + await resolutions.GetAsync( + request.ApplicationId, + cancellationToken).ConfigureAwait(false); + if (existing is not null) + { + return Result.Success(Matches( + existing, + request, + scopeId, + anchor.AnchoredAtUtc) + ? StaffWorkspaceOnboardingIdentityAnchorResolutionStatus + .AlreadyRecorded + : StaffWorkspaceOnboardingIdentityAnchorResolutionStatus + .Conflict); + } + + await resolutions.AddAsync( + new StaffIdentityProvisioningAnchorResolutionRecord( + scopeId, + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + request.ApplicationId, + request.StaffMemberId, + request.WorkspaceApplicationVersion, + request.Disposition, + request.ResolutionEventId, + ResolutionTime( + request.ResolvedAtUtc, + anchor.AnchoredAtUtc)), + cancellationToken).ConfigureAwait(false); + return Result.Success( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus.Recorded); + } + + private static StaffWorkspaceOnboardingIdentityAnchorOutcome Outcome( + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest request, + Dictionary anchors, + Dictionary resolutions, + Dictionary targets) + { + if (!anchors.TryGetValue( + request.ApplicationId, + out StaffIdentityProvisioningAnchorRecord? anchor)) + { + return new( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Absent, + null, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Unknown, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Unknown, + null, + null, + null); + } + + bool anchorMatchesProtocol = + anchor.SourceKind == + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding && + anchor.ResolutionEventId.HasValue && + anchor.ResolutionEventId.Value != Guid.Empty && + anchor.ResolutionEventId.Value != anchor.SourceId; + + if (!targets.TryGetValue( + anchor.StaffMemberId, + out StaffMemberSafetyEvidence? target)) + { + return new( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Corrupt, + anchor.StaffMemberId, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Missing, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Missing, + null, + null, + anchor.ResolutionEventId); + } + + bool hasResolution = resolutions.TryGetValue( + request.ApplicationId, + out StaffIdentityProvisioningAnchorResolutionRecord? resolution); + bool resolutionMatches = anchorMatchesProtocol && + (!hasResolution || + (resolution!.SourceKind == + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding && + resolution.StaffMemberId == anchor.StaffMemberId && + resolution.ResolutionEventId == + anchor.ResolutionEventId && + string.Equals( + resolution.ScopeId, + anchor.ScopeId, + StringComparison.Ordinal))); + return new( + request.ApplicationId, + resolutionMatches + ? hasResolution + ? StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Resolved + : StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Unresolved + : StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Corrupt, + anchor.StaffMemberId, + ToLifecycle(target.Status), + SubjectMatch(target.AuthSubjectId, request.ExpectedAuthSubjectId), + hasResolution && resolutionMatches + ? resolution!.WorkspaceApplicationVersion + : null, + hasResolution && resolutionMatches + ? resolution!.Disposition + : null, + anchor.ResolutionEventId); + } + + private static StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + ToLifecycle(StaffMemberState status) => status switch + { + StaffMemberState.Active => + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffMemberState.Suspended => + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Suspended, + StaffMemberState.Departed => + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Departed, + StaffMemberState.Anonymised => + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Anonymised, + _ => StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Unknown + }; + + private static StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + SubjectMatch( + string? actualAuthSubjectId, + string expectedAuthSubjectId) => + actualAuthSubjectId is null + ? StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Missing + : string.Equals( + actualAuthSubjectId, + expectedAuthSubjectId.Trim(), + StringComparison.Ordinal) + ? StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact + : StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Mismatch; + + private static bool IsValid( + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest? request) + { + if (request is null || request.ApplicationId == Guid.Empty || + string.IsNullOrWhiteSpace(request.ExpectedAuthSubjectId) || + request.ExpectedAuthSubjectId.Any(char.IsControl)) + { + return false; + } + + Result subject = StaffAuthSubject.Create( + request.ExpectedAuthSubjectId); + return subject.IsSuccess && subject.Value.Value is not null; + } + + private static bool IsValid( + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest? request) => + request is not null && + request.ResolutionEventId != Guid.Empty && + request.ApplicationId != Guid.Empty && + request.ResolutionEventId != request.ApplicationId && + request.StaffMemberId != Guid.Empty && + request.WorkspaceApplicationVersion >= 1 && + request.Disposition is >= + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted and <= + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted && + request.ResolvedAtUtc != default; + + private static bool Matches( + StaffIdentityProvisioningAnchorResolutionRecord existing, + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest request, + string scopeId, + DateTimeOffset anchoredAtUtc) => + string.Equals(existing.ScopeId, scopeId, StringComparison.Ordinal) && + existing.SourceKind == + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding && + existing.SourceId == request.ApplicationId && + existing.StaffMemberId == request.StaffMemberId && + existing.WorkspaceApplicationVersion == + request.WorkspaceApplicationVersion && + existing.Disposition == request.Disposition && + existing.ResolutionEventId == request.ResolutionEventId && + existing.ResolvedAtUtc.ToUniversalTime() == + ResolutionTime(request.ResolvedAtUtc, anchoredAtUtc); + + private static DateTimeOffset ResolutionTime( + DateTimeOffset requestedAtUtc, + DateTimeOffset anchoredAtUtc) + { + DateTimeOffset normalizedRequest = + NormalizeResolutionTime(requestedAtUtc); + DateTimeOffset normalizedAnchor = + NormalizeResolutionTime(anchoredAtUtc); + return normalizedRequest >= normalizedAnchor + ? normalizedRequest + : normalizedAnchor; + } + + private static DateTimeOffset NormalizeResolutionTime( + DateTimeOffset resolvedAtUtc) + { + DateTimeOffset utc = resolvedAtUtc.ToUniversalTime(); + return new DateTimeOffset( + utc.Ticks - (utc.Ticks % TimeSpan.TicksPerMicrosecond), + TimeSpan.Zero); + } +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffIdentityProvisioningAnchorRepository.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffIdentityProvisioningAnchorRepository.cs new file mode 100644 index 00000000..791e42bf --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffIdentityProvisioningAnchorRepository.cs @@ -0,0 +1,56 @@ +namespace BunkFy.Modules.Staff.Application.Ports; + +public interface IStaffIdentityProvisioningAnchorRepository +{ + async Task> ListAsync( + IReadOnlyList sources, + CancellationToken cancellationToken) + { + List records = []; + foreach (StaffIdentityProvisioningSourceKey source in sources) + { + StaffIdentityProvisioningAnchorRecord? record = await this.GetAsync( + source.SourceKind, + source.SourceId, + cancellationToken).ConfigureAwait(false); + if (record is not null) + { + records.Add(record); + } + } + + return records; + } + + Task GetAsync( + StaffIdentityProvisioningSourceKind sourceKind, + Guid sourceId, + CancellationToken cancellationToken); + + Task HasWorkspaceOnboardingAsync( + Guid staffMemberId, + CancellationToken cancellationToken); + + Task AddAsync( + StaffIdentityProvisioningAnchorRecord anchor, + CancellationToken cancellationToken); +} + +public sealed record StaffIdentityProvisioningSourceKey( + StaffIdentityProvisioningSourceKind SourceKind, + Guid SourceId); + +public sealed record StaffIdentityProvisioningAnchorRecord( + string ScopeId, + StaffIdentityProvisioningSourceKind SourceKind, + Guid SourceId, + Guid StaffMemberId, + DateTimeOffset AnchoredAtUtc, + Guid? ResolutionEventId = null); + +public enum StaffIdentityProvisioningSourceKind +{ + Unknown = 0, + WorkspaceOnboarding = 1, + OrganizationMembership = 2 +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffIdentityProvisioningAnchorResolutionRepository.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffIdentityProvisioningAnchorResolutionRepository.cs new file mode 100644 index 00000000..f685434e --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffIdentityProvisioningAnchorResolutionRepository.cs @@ -0,0 +1,48 @@ +namespace BunkFy.Modules.Staff.Application.Ports; + +using BunkFy.Modules.Staff.Contracts; + +public interface IStaffIdentityProvisioningAnchorResolutionRepository +{ + async Task> ListAsync( + IReadOnlyList sourceIds, + CancellationToken cancellationToken) + { + List records = []; + foreach (Guid sourceId in sourceIds) + { + StaffIdentityProvisioningAnchorResolutionRecord? record = + await this.GetAsync(sourceId, cancellationToken) + .ConfigureAwait(false); + if (record is not null) + { + records.Add(record); + } + } + + return records; + } + + Task GetAsync( + Guid sourceId, + CancellationToken cancellationToken); + + Task HasUnresolvedWorkspaceOnboardingAsync( + Guid staffMemberId, + CancellationToken cancellationToken); + + Task AddAsync( + StaffIdentityProvisioningAnchorResolutionRecord resolution, + CancellationToken cancellationToken); +} + +public sealed record StaffIdentityProvisioningAnchorResolutionRecord( + string ScopeId, + StaffIdentityProvisioningSourceKind SourceKind, + Guid SourceId, + Guid StaffMemberId, + long WorkspaceApplicationVersion, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition Disposition, + Guid ResolutionEventId, + DateTimeOffset ResolvedAtUtc); diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffIdentityProvisioningAnchorWriter.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffIdentityProvisioningAnchorWriter.cs new file mode 100644 index 00000000..f2250084 --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffIdentityProvisioningAnchorWriter.cs @@ -0,0 +1,8 @@ +namespace BunkFy.Modules.Staff.Application.Ports; + +public interface IStaffIdentityProvisioningAnchorWriter +{ + Task AddAsync( + StaffIdentityProvisioningAnchorRecord anchor, + CancellationToken cancellationToken); +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffMemberRepository.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffMemberRepository.cs index 0d6e53e1..0b1bf850 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffMemberRepository.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffMemberRepository.cs @@ -6,14 +6,49 @@ namespace BunkFy.Modules.Staff.Application.Ports; public interface IStaffMemberRepository { + async Task> ListSafetyEvidenceAsync( + IReadOnlyList staffMemberIds, + CancellationToken cancellationToken) + { + List records = []; + foreach (Guid staffMemberId in staffMemberIds) + { + StaffMember? member = await this.GetForSafetyTransitionAsync( + staffMemberId, + cancellationToken).ConfigureAwait(false); + if (member is not null) + { + records.Add(new( + member.Id, + member.AuthSubjectId, + member.Status)); + } + } + + return records; + } + Task AddAsync(StaffMember member, CancellationToken cancellationToken); Task GetAsync(Guid staffMemberId, CancellationToken cancellationToken); + Task ReloadOperationalAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + this.GetAsync(staffMemberId, cancellationToken); Task GetForDataRightsAsync( Guid staffMemberId, CancellationToken cancellationToken); Task GetForSafetyTransitionAsync( Guid staffMemberId, CancellationToken cancellationToken); + Task ReloadForSafetyTransitionAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + this.GetForSafetyTransitionAsync( + staffMemberId, + cancellationToken); + Task GetForSafetyTransitionByAuthSubjectAsync( + string authSubjectId, + CancellationToken cancellationToken); Task GetByAuthSubjectAsync(string authSubjectId, CancellationToken cancellationToken); Task GetDirectoryAsync(Guid staffMemberId, CancellationToken cancellationToken); Task GetDirectoryAtPropertyAsync( @@ -36,3 +71,8 @@ Task EmployeeNumberExistsAsync(string employeeNumber, Guid? exceptStaffMem Task AuthSubjectExistsAsync(string authSubjectId, Guid? exceptStaffMemberId, CancellationToken cancellationToken); } + +public sealed record StaffMemberSafetyEvidence( + Guid StaffMemberId, + string? AuthSubjectId, + StaffMemberState Status); diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffOnboardingProvisioningOperationRepository.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffOnboardingProvisioningOperationRepository.cs index eac06dcc..5cac8d4b 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffOnboardingProvisioningOperationRepository.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Ports/IStaffOnboardingProvisioningOperationRepository.cs @@ -2,6 +2,25 @@ namespace BunkFy.Modules.Staff.Application.Ports; public interface IStaffOnboardingProvisioningOperationRepository { + async Task> ListAsync( + IReadOnlyList operationIds, + CancellationToken cancellationToken) + { + List records = []; + foreach (Guid operationId in operationIds) + { + StaffMemberMutationOperationRecord? record = await this.GetAsync( + operationId, + cancellationToken).ConfigureAwait(false); + if (record is not null) + { + records.Add(record); + } + } + + return records; + } + Task GetAsync( Guid operationId, CancellationToken cancellationToken); diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Queries/InspectStaffWorkspaceOnboardingAnchorsQuery.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Queries/InspectStaffWorkspaceOnboardingAnchorsQuery.cs new file mode 100644 index 00000000..6c6f43ad --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Queries/InspectStaffWorkspaceOnboardingAnchorsQuery.cs @@ -0,0 +1,8 @@ +namespace BunkFy.Modules.Staff.Application.Queries; + +using BunkFy.Modules.Staff.Contracts; +using Gma.Framework.Cqrs; + +public sealed record InspectStaffIdentityProvisioningAnchorsQuery( + IReadOnlyList Candidates) + : IQuery>; diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Queries/ReadStaffWorkspaceOnboardingIdentityAnchorOutcomesQuery.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Queries/ReadStaffWorkspaceOnboardingIdentityAnchorOutcomesQuery.cs new file mode 100644 index 00000000..0b51edde --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Queries/ReadStaffWorkspaceOnboardingIdentityAnchorOutcomesQuery.cs @@ -0,0 +1,10 @@ +namespace BunkFy.Modules.Staff.Application.Queries; + +using BunkFy.Modules.Staff.Contracts; +using Gma.Framework.Cqrs; + +public sealed record ReadStaffWorkspaceOnboardingIdentityAnchorOutcomesQuery( + IReadOnlyList + Requests) + : IQuery>; diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/StaffApplicationErrors.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/StaffApplicationErrors.cs index 22cbbce8..1269706d 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/StaffApplicationErrors.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/StaffApplicationErrors.cs @@ -23,6 +23,30 @@ public static class StaffApplicationErrors public static readonly Error CreationOperationConflict = new( "Staff.CreationOperationConflict", "The Staff creation operation was already used for different profile data."); + public static readonly Error IdentityProvisioningSourceInvalid = new( + "Staff.IdentityProvisioningSourceInvalid", + "A valid stable Staff identity provisioning source id is required."); + public static readonly Error IdentityProvisioningAnchorConflict = new( + "Staff.IdentityProvisioningAnchorConflict", + "The Staff identity provisioning source is anchored to a different identity."); + public static readonly Error IdentityProvisioningAnchorCorrupt = new( + "Staff.IdentityProvisioningAnchorCorrupt", + "The Staff identity provisioning anchor target is unavailable."); + public static readonly Error IdentityAnchorCutoverRequestInvalid = new( + "Staff.IdentityAnchorCutoverRequestInvalid", + "The Staff identity-anchor cutover request is invalid."); + public static readonly Error IdentityAnchorCutoverBlocked = new( + "Staff.IdentityAnchorCutoverBlocked", + "The Staff identity-anchor cutover contains ambiguous or conflicting evidence."); + public static readonly Error IdentityAnchorLifecycleRequestInvalid = new( + "Staff.IdentityAnchorLifecycleRequestInvalid", + "The Staff identity-anchor lifecycle request is invalid."); + public static readonly Error IdentityAnchorResolutionRequired = new( + "Staff.IdentityAnchorResolutionRequired", + "Workspace onboarding must finish redaction before this Staff identity lifecycle change can continue."); + public static readonly Error IdentityAnchorAccessClosureRequired = new( + "Staff.IdentityAnchorAccessClosureRequired", + "Workspace onboarding access must be closed before this Staff account link can change."); public static readonly Error OnboardingOperationInvalid = new( "Staff.OnboardingOperationInvalid", "A valid Staff onboarding operation id is required."); diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Validation/BootstrapStaffIdentityCommandValidator.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Validation/BootstrapStaffIdentityCommandValidator.cs index 0771ea15..3bc59c00 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Application/Validation/BootstrapStaffIdentityCommandValidator.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Application/Validation/BootstrapStaffIdentityCommandValidator.cs @@ -14,6 +14,11 @@ public IEnumerable Validate(BootstrapStaffIdentityCommand command) yield return "OperationId is required."; } + if (command.SourceId == Guid.Empty) + { + yield return "SourceId is required."; + } + string authSubjectId = command.AuthSubjectId?.Trim() ?? string.Empty; if (authSubjectId.Length is 0 or > StaffContractLimits.AuthSubjectIdMaxLength) { diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Events/StaffIdentityProvisioningAnchorCreatedIntegrationEvent.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Events/StaffIdentityProvisioningAnchorCreatedIntegrationEvent.cs new file mode 100644 index 00000000..ca663ab2 --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Events/StaffIdentityProvisioningAnchorCreatedIntegrationEvent.cs @@ -0,0 +1,56 @@ +namespace BunkFy.Modules.Staff.Contracts; + +using Gma.Framework.Messaging; +using Gma.Framework.Naming; +using Gma.Framework.Scoping; + +[IntegrationEventName(EventType)] +[IntegrationEventVersion(EventVersion)] +[ScopeAware] +public sealed record StaffIdentityProvisioningAnchorCreatedIntegrationEvent + : IntegrationEvent, IScopedIntegrationEvent +{ + public const string EventType = + "workspace-onboarding-identity-anchor-created"; + public const int EventVersion = 1; + + public StaffIdentityProvisioningAnchorCreatedIntegrationEvent( + Guid eventId, + string scopeId, + DateTimeOffset occurredAtUtc, + Guid applicationId, + Guid staffMemberId, + Guid resolutionEventId) + : base(eventId, occurredAtUtc, EventType, EventVersion) + { + this.ScopeId = ScopeIds.Normalize(scopeId, nameof(scopeId)); + this.ApplicationId = IntegrationEventContractGuards.RequireId( + applicationId, + nameof(applicationId)); + this.StaffMemberId = IntegrationEventContractGuards.RequireId( + staffMemberId, + nameof(staffMemberId)); + this.ResolutionEventId = IntegrationEventContractGuards.RequireId( + resolutionEventId, + nameof(resolutionEventId)); + if (eventId != this.ApplicationId) + { + throw new ArgumentException( + "The anchor-created event id does not match its application coordinate.", + nameof(eventId)); + } + + if (this.ResolutionEventId == this.ApplicationId) + { + throw new ArgumentException( + "The resolution event id must be distinct from its application coordinate.", + nameof(resolutionEventId)); + } + } + + public string ScopeId { get; } + public Guid ApplicationId { get; } + public Guid StaffMemberId { get; } + public Guid ResolutionEventId { get; } + string IScopedIntegrationEvent.ScopeId => this.ScopeId; +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Events/StaffIntegrationSubjects.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Events/StaffIntegrationSubjects.cs index fa13f8a2..36f02fe3 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Events/StaffIntegrationSubjects.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Events/StaffIntegrationSubjects.cs @@ -21,4 +21,13 @@ public static string CreateAuthSubjectChanged(string prefix = IntegrationEventNa public static string CreatePropertyAssignmentChanged(string prefix = IntegrationEventNaming.DefaultSubjectPrefix) => IntegrationEventNaming.CreateSubject(prefix, StaffModuleMetadata.Name, StaffPropertyAssignmentChangedIntegrationEvent.EventType, StaffPropertyAssignmentChangedIntegrationEvent.EventVersion); + + public static string CreateIdentityProvisioningAnchorCreated( + string prefix = IntegrationEventNaming.DefaultSubjectPrefix) => + IntegrationEventNaming.CreateSubject( + prefix, + StaffModuleMetadata.Name, + StaffIdentityProvisioningAnchorCreatedIntegrationEvent.EventType, + StaffIdentityProvisioningAnchorCreatedIntegrationEvent + .EventVersion); } diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Identity/IStaffIdentityBootstrapper.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Identity/IStaffIdentityBootstrapper.cs index e8788add..5da10f36 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Identity/IStaffIdentityBootstrapper.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Identity/IStaffIdentityBootstrapper.cs @@ -9,6 +9,7 @@ Task BootstrapAsync( public sealed record StaffIdentityBootstrapRequest( Guid OperationId, + Guid SourceId, string AuthSubjectId, string DisplayName, string? WorkEmail, diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Identity/IStaffOnboardingProvisioner.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Identity/IStaffOnboardingProvisioner.cs index 36f7f2f0..a5cfdc8b 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Identity/IStaffOnboardingProvisioner.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Identity/IStaffOnboardingProvisioner.cs @@ -22,4 +22,5 @@ public sealed record StaffOnboardingProvisioningRequest( public sealed record StaffOnboardingProvisioningResult( bool IsSuccess, Guid? StaffMemberId, - string? ErrorCode); + string? ErrorCode, + Guid? ResolutionEventId = null); diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Identity/IStaffWorkspaceOnboardingAnchorCutover.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Identity/IStaffWorkspaceOnboardingAnchorCutover.cs new file mode 100644 index 00000000..98432d83 --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Identity/IStaffWorkspaceOnboardingAnchorCutover.cs @@ -0,0 +1,59 @@ +namespace BunkFy.Modules.Staff.Contracts; + +using System.Text.Json.Serialization; + +public interface IStaffIdentityProvisioningAnchorCutover +{ + Task InspectAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken = default); + + Task ApplyAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken = default); +} + +public static class StaffWorkspaceOnboardingAnchorCutoverLimits +{ + public const int MaximumBatchSize = 500; +} + +public sealed record StaffIdentityProvisioningAnchorCandidate( + StaffIdentityProvisioningAnchorSourceKind SourceKind, + Guid SourceId, + Guid? StaffMemberId, + [property: JsonIgnore] string? ExpectedAuthSubjectId = null, + bool ReviewedErasedTarget = false); + +public sealed record StaffIdentityProvisioningAnchorInspection( + bool IsSuccess, + IReadOnlyList Items, + string? ErrorCode); + +public sealed record StaffIdentityProvisioningAnchorCandidateInspection( + StaffIdentityProvisioningAnchorSourceKind SourceKind, + Guid SourceId, + StaffIdentityProvisioningAnchorCutoverDisposition Disposition); + +public sealed record StaffIdentityProvisioningAnchorApplyResult( + bool IsSuccess, + int AppliedCount, + int AlreadyAnchoredCount, + string? ErrorCode); + +public enum StaffIdentityProvisioningAnchorSourceKind +{ + Unknown = 0, + WorkspaceOnboarding = 1, + OrganizationMembership = 2 +} + +public enum StaffIdentityProvisioningAnchorCutoverDisposition +{ + Unknown = 0, + AlreadyAnchored = 1, + SeedableFromWorkspace = 2, + SeedableFromReviewedOwnerMap = 3, + Ambiguous = 4, + Conflict = 5 +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Identity/IStaffWorkspaceOnboardingIdentityAnchorLifecycle.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Identity/IStaffWorkspaceOnboardingIdentityAnchorLifecycle.cs new file mode 100644 index 00000000..0dcfb661 --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Identity/IStaffWorkspaceOnboardingIdentityAnchorLifecycle.cs @@ -0,0 +1,110 @@ +namespace BunkFy.Modules.Staff.Contracts; + +using System.Text.Json.Serialization; + +public interface IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader +{ + Task> + ReadAsync( + IReadOnlyList + requests, + CancellationToken cancellationToken = default); + + async Task ReadAsync( + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest request, + CancellationToken cancellationToken = default) + { + IReadOnlyList outcomes = + await this.ReadAsync([request], cancellationToken) + .ConfigureAwait(false); + return outcomes.Count == 1 && + outcomes[0].ApplicationId == request.ApplicationId + ? outcomes[0] + : throw new InvalidOperationException( + "Staff identity-anchor outcome coordinates are invalid."); + } +} + +public sealed record StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest( + Guid ApplicationId, + [property: JsonIgnore] string ExpectedAuthSubjectId); + +public interface IStaffWorkspaceOnboardingIdentityAnchorResolutionRecorder +{ + Task RecordAsync( + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest request, + CancellationToken cancellationToken = default); +} + +public sealed record StaffWorkspaceOnboardingIdentityAnchorOutcome( + Guid ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus Status, + Guid? StaffMemberId, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle TargetLifecycle, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch SubjectMatch, + long? WorkspaceApplicationVersion, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition? + ResolutionDisposition, + Guid? ResolutionEventId = null); + +public sealed record StaffWorkspaceOnboardingIdentityAnchorResolutionRequest( + Guid ResolutionEventId, + Guid ApplicationId, + Guid StaffMemberId, + long WorkspaceApplicationVersion, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition Disposition, + DateTimeOffset ResolvedAtUtc); + +public sealed record StaffWorkspaceOnboardingIdentityAnchorResolutionResult( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus Status); + +public static class StaffWorkspaceOnboardingIdentityAnchorLifecycleLimits +{ + public const int MaximumBatchSize = 500; +} + +public enum StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus +{ + Unknown = 0, + Absent = 1, + Unresolved = 2, + Resolved = 3, + Corrupt = 4 +} + +public enum StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle +{ + Unknown = 0, + Active = 1, + Suspended = 2, + Departed = 3, + Anonymised = 4, + Missing = 5 +} + +public enum StaffWorkspaceOnboardingIdentityAnchorSubjectMatch +{ + Unknown = 0, + Exact = 1, + Missing = 2, + Mismatch = 3 +} + +public enum StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition +{ + Unknown = 0, + CompletedRedacted = 1, + RejectedRedacted = 2, + SupersededRedacted = 3, + ExpiredRedacted = 4, + WithdrawnRedacted = 5 +} + +public enum StaffWorkspaceOnboardingIdentityAnchorResolutionStatus +{ + Unknown = 0, + Recorded = 1, + AlreadyRecorded = 2, + AnchorAbsent = 3, + Conflict = 4 +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Metadata/StaffModuleMetadata.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Metadata/StaffModuleMetadata.cs index e558dcf8..a52768f0 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Metadata/StaffModuleMetadata.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Metadata/StaffModuleMetadata.cs @@ -43,6 +43,8 @@ public static class StaffModuleMetadata .WithPublishedEvent() .WithPublishedEvent() .WithPublishedEvent() + .WithPublishedEvent< + StaffIdentityProvisioningAnchorCreatedIntegrationEvent>() .WithPublishedEvent() .WithTask() .WithProfile(StaffProfiles.Default) diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Metadata/StaffTenantTerminationMetadata.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Metadata/StaffTenantTerminationMetadata.cs index cdc4da1c..a05b3832 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Metadata/StaffTenantTerminationMetadata.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Contracts/Metadata/StaffTenantTerminationMetadata.cs @@ -7,17 +7,21 @@ public static class StaffTenantTerminationMetadata { public const string OwnerKey = StaffModuleMetadata.Name; public const string DependencyOwnerKey = "guests"; - public const int CatalogVersion = 4; - public const int PersonalDataCatalogVersion = 19; + public const int CatalogVersion = 5; + public const int PersonalDataCatalogVersion = 20; public const string ExportCatalogId = "staff.tenant-portability"; public const int ExportCatalogSchemaVersion = 1; public const string ExportSchemaId = "staff.tenant-termination-export"; - public const int ExportSchemaVersion = 3; + public const int ExportSchemaVersion = 5; public const string StaffMemberRecordType = "staff-member"; public const string PropertyAssignmentRecordType = "property-assignment"; public const string MemberMutationOperationRecordType = "member-mutation-operation"; + public const string IdentityProvisioningAnchorRecordType = + "identity-provisioning-anchor"; + public const string IdentityProvisioningAnchorResolutionRecordType = + "identity-provisioning-anchor-resolution"; public const string DataRightsCorrectionReceiptRecordType = "data-rights-correction-receipt"; public const string ProcessingRestrictionRecordType = @@ -45,6 +49,8 @@ public static class StaffTenantTerminationMetadata Array.AsReadOnly( [ StaffMemberRecordType, + IdentityProvisioningAnchorRecordType, + IdentityProvisioningAnchorResolutionRecordType, PropertyAssignmentRecordType, MemberMutationOperationRecordType, DataRightsCorrectionReceiptRecordType, @@ -72,6 +78,8 @@ public static class StaffTenantTerminationMetadata "staff.staff-attribution", "staff.assignment-record", "staff.member-mutation-operation", + "staff.identity-provisioning-anchor", + "staff.identity-provisioning-anchor-resolution", "staff.data-rights-proof", "staff.processing-restriction", "staff.employment-governance", diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence.PostgreSqlMigrations/Migrations/20260811110753_AddStaffIdentityProvisioningAnchors.Designer.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence.PostgreSqlMigrations/Migrations/20260811110753_AddStaffIdentityProvisioningAnchors.Designer.cs new file mode 100644 index 00000000..7852045d --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence.PostgreSqlMigrations/Migrations/20260811110753_AddStaffIdentityProvisioningAnchors.Designer.cs @@ -0,0 +1,1966 @@ +// +using System; +using BunkFy.Modules.Staff.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.Staff.Persistence.PostgreSqlMigrations.Migrations +{ + [DbContext(typeof(StaffDbContext))] + [Migration("20260811110753_AddStaffIdentityProvisioningAnchors")] + partial class AddStaffIdentityProvisioningAnchors + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("staff") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymisedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthSubjectId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DepartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DepartureEffectiveOn") + .HasColumnType("date"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisplayNameSearch") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmployeeNumber") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EmployeeNumberSearch") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("JobTitle") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastChangedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastChangedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LegalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LegalNameSearch") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProjectionOrdinal") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectionOrdinal")); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SuspendedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("WorkEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("WorkEmailSearch") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("WorkPhone") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkPhoneSearch") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ProjectionOrdinal") + .IsUnique(); + + b.HasIndex("ScopeId", "AuthSubjectId") + .IsUnique(); + + b.HasIndex("ScopeId", "EmployeeNumberSearch") + .IsUnique(); + + b.HasIndex("ScopeId", "Status", "DisplayNameSearch", "Id"); + + b.HasIndex("ScopeId", "Status", "ProjectionOrdinal", "Id"); + + b.ToTable("staff_members", "staff", t => + { + t.HasCheckConstraint("CK_staff_members_created_by", "length(trim(\"CreatedBy\")) > 0"); + + t.HasCheckConstraint("CK_staff_members_display_name", "length(trim(\"DisplayName\")) > 0"); + + t.HasCheckConstraint("CK_staff_members_last_changed_by", "length(trim(\"LastChangedBy\")) > 0"); + + t.HasCheckConstraint("CK_staff_members_lifecycle", "(\"Status\" = 1 AND \"SuspendedAtUtc\" IS NULL AND \"DepartedAtUtc\" IS NULL AND \"DepartureEffectiveOn\" IS NULL AND \"AnonymisedAtUtc\" IS NULL) OR (\"Status\" = 2 AND \"SuspendedAtUtc\" IS NOT NULL AND \"DepartedAtUtc\" IS NULL AND \"DepartureEffectiveOn\" IS NULL AND \"AnonymisedAtUtc\" IS NULL) OR (\"Status\" = 3 AND \"SuspendedAtUtc\" IS NULL AND \"DepartedAtUtc\" IS NOT NULL AND \"DepartureEffectiveOn\" IS NOT NULL AND \"AnonymisedAtUtc\" IS NULL) OR (\"Status\" = 4 AND \"SuspendedAtUtc\" IS NULL AND \"DepartedAtUtc\" IS NOT NULL AND \"DepartureEffectiveOn\" IS NOT NULL AND \"AnonymisedAtUtc\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_staff_members_version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.DataRights.StaffAnonymisationReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + 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("EventId") + .HasColumnType("uuid"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("OperationRevision") + .HasColumnType("bigint"); + + b.Property("Reason") + .HasColumnType("integer"); + + b.Property("ResultingOperationLockRevision") + .HasColumnType("bigint"); + + b.Property("ResultingStaffVersion") + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedOperationLockRevision") + .HasColumnType("bigint"); + + b.Property("SelectedStaffVersion") + .HasColumnType("bigint"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.Property("StateBindingsSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "CanonicalSha256"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("ScopeId", "StaffMemberId", "ResultingStaffVersion") + .IsUnique(); + + b.HasIndex("ScopeId", "CaseId", "ApprovalRevision", "OperationRevision", "StaffMemberId") + .IsUnique(); + + b.ToTable("staff_anonymisation_receipts", "staff", t => + { + t.HasCheckConstraint("CK_staff_anonymisation_receipts_actor", "length(trim(\"ActorId\")) > 0"); + + t.HasCheckConstraint("CK_staff_anonymisation_receipts_contract", "\"ContractVersion\" = 1"); + + t.HasCheckConstraint("CK_staff_anonymisation_receipts_digests", "char_length(\"ApprovalEvidenceSha256\") = 64 AND char_length(\"StateBindingsSha256\") = 64 AND char_length(\"CanonicalSha256\") = 64"); + + t.HasCheckConstraint("CK_staff_anonymisation_receipts_lock_revisions", "\"SelectedOperationLockRevision\" >= 1 AND \"ResultingOperationLockRevision\" = \"SelectedOperationLockRevision\" + 1"); + + t.HasCheckConstraint("CK_staff_anonymisation_receipts_outcome", "\"Disposition\" = 1 AND \"Reason\" = 1"); + + t.HasCheckConstraint("CK_staff_anonymisation_receipts_revisions", "\"ApprovalRevision\" >= 1 AND \"OperationRevision\" > \"ApprovalRevision\""); + + t.HasCheckConstraint("CK_staff_anonymisation_receipts_versions", "\"SelectedStaffVersion\" >= 1 AND \"ResultingStaffVersion\" = \"SelectedStaffVersion\" + 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.DataRights.StaffAnonymisationRestoreReceipt", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CanonicalSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + 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("ReplayedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ResultingStaffVersion") + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.Property("TombstoneRevision") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "StaffMemberId", "LedgerEntryId") + .IsUnique(); + + b.ToTable("staff_anonymisation_restore_receipts", "staff", t => + { + t.HasCheckConstraint("CK_staff_anonymisation_restore_receipts_contract", "\"ContractVersion\" = 1"); + + t.HasCheckConstraint("CK_staff_anonymisation_restore_receipts_digests", "char_length(\"OwnerReceiptSha256\") = 64 AND char_length(\"CanonicalSha256\") = 64"); + + t.HasCheckConstraint("CK_staff_anonymisation_restore_receipts_identity", "\"LedgerEntryId\" = \"Id\""); + + t.HasCheckConstraint("CK_staff_anonymisation_restore_receipts_versions", "\"OwnerReceiptContractVersion\" >= 1 AND \"ResultingStaffVersion\" >= 1 AND \"TombstoneRevision\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.DataRights.StaffAnonymisationTombstone", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Authority") + .HasColumnType("integer"); + + 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("OwnerReceiptSha256") + .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("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId", "CompletedAtUtc", "Id"); + + b.ToTable("staff_anonymisation_tombstones", "staff", t => + { + t.HasCheckConstraint("CK_staff_anonymisation_tombstones_authority", "\"Authority\" IN (1, 2)"); + + t.HasCheckConstraint("CK_staff_anonymisation_tombstones_contract", "\"ContractVersion\" = 3"); + + t.HasCheckConstraint("CK_staff_anonymisation_tombstones_receipt_digest", "char_length(\"OwnerReceiptSha256\") = 64"); + + t.HasCheckConstraint("CK_staff_anonymisation_tombstones_restore_proof", "\"Authority\" <> 2 OR (\"LedgerEntryId\" IS NULL AND \"LastReplayedAtUtc\" IS NULL)"); + + t.HasCheckConstraint("CK_staff_anonymisation_tombstones_revision", "\"Revision\" >= 1"); + + t.HasCheckConstraint("CK_staff_anonymisation_tombstones_state", "\"State\" = 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.DataRights.StaffDataHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PlacedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PlacedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + 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("StaffMemberId") + .HasColumnType("uuid"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId", "StaffMemberId", "State", "PlacedAtUtc", "Id"); + + b.ToTable("staff_data_holds", "staff", t => + { + t.HasCheckConstraint("CK_staff_data_holds_lifecycle", "(\"State\" = 1 AND \"Version\" = 1 AND \"ReleasedBy\" IS NULL AND \"ReleasedAtUtc\" IS NULL) OR (\"State\" = 2 AND \"Version\" = 2 AND \"ReleasedBy\" IS NOT NULL AND \"ReleasedAtUtc\" IS NOT NULL AND \"ReleasedAtUtc\" >= \"PlacedAtUtc\")"); + + t.HasCheckConstraint("CK_staff_data_holds_state", "\"State\" IN (1, 2)"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.DataRights.StaffDataHoldReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HoldId") + .HasColumnType("uuid"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ResultingHoldVersion") + .HasColumnType("bigint"); + + 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", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("ScopeId", "HoldId", "Action") + .IsUnique(); + + b.HasIndex("ScopeId", "StaffMemberId", "CompletedAtUtc", "Id"); + + b.ToTable("staff_data_hold_receipts", "staff", t => + { + t.HasCheckConstraint("CK_staff_data_hold_receipts_action", "\"Action\" IN (1, 2)"); + + t.HasCheckConstraint("CK_staff_data_hold_receipts_versions", "\"SelectedStaffVersion\" >= 1 AND ((\"Action\" = 1 AND \"ResultingHoldVersion\" = 1) OR (\"Action\" = 2 AND \"ResultingHoldVersion\" = 2))"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.DataRights.StaffDataRightsCorrectionReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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("ProfileEventId") + .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.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "CompletionEventId") + .IsUnique(); + + b.HasIndex("ScopeId", "ExecutionId") + .IsUnique(); + + b.HasIndex("ScopeId", "ProfileEventId") + .IsUnique(); + + b.HasIndex("ScopeId", "CaseId", "ApprovalRevision"); + + b.HasIndex("ScopeId", "StaffMemberId", "CurrentRecordVersion"); + + b.ToTable("data_rights_correction_receipts", "staff", t => + { + t.HasCheckConstraint("CK_staff_data_rights_correction_receipts_approval", "\"ApprovalRevision\" >= 1"); + + t.HasCheckConstraint("CK_staff_data_rights_correction_receipts_contract", "\"ContractVersion\" = 1"); + + t.HasCheckConstraint("CK_staff_data_rights_correction_receipts_digest", "char_length(\"RequestSha256\") = 64"); + + t.HasCheckConstraint("CK_staff_data_rights_correction_receipts_fields", "\"ChangedFieldsMask\" BETWEEN 1 AND 127"); + + t.HasCheckConstraint("CK_staff_data_rights_correction_receipts_versions", "\"SelectedRecordVersion\" >= 1 AND \"CurrentRecordVersion\" = \"SelectedRecordVersion\" + 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.DataRights.StaffProcessingRestriction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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("ApplySelectedStaffVersion") + .HasColumnType("bigint"); + + b.Property("ReleaseApprovalRevision") + .HasColumnType("bigint"); + + b.Property("ReleaseCaseId") + .HasColumnType("uuid"); + + b.Property("ReleaseSelectedStaffVersion") + .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("StaffMemberId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "StaffMemberId", "ApplyCaseId", "ApplyApprovalRevision") + .IsUnique(); + + b.HasIndex("ScopeId", "StaffMemberId", "ReleaseCaseId", "ReleaseApprovalRevision") + .IsUnique(); + + b.HasIndex("ScopeId", "StaffMemberId", "Status", "AppliedAtUtc"); + + b.ToTable("staff_processing_restrictions", "staff", t => + { + t.HasCheckConstraint("CK_staff_processing_restrictions_apply_approval", "\"ApplyApprovalRevision\" >= 1 AND \"ApplySelectedStaffVersion\" >= 1"); + + t.HasCheckConstraint("CK_staff_processing_restrictions_lifecycle", "(\"Status\" = 1 AND \"ReleaseCaseId\" IS NULL AND \"ReleaseApprovalRevision\" IS NULL AND \"ReleaseSelectedStaffVersion\" 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 \"ReleaseSelectedStaffVersion\" >= 1 AND \"ReleasedBy\" IS NOT NULL AND \"ReleasedAtUtc\" IS NOT NULL AND \"ReleasedAtUtc\" >= \"AppliedAtUtc\" AND \"Version\" >= 2)"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.DataRights.StaffProcessingRestrictionProjection", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StaffMemberId") + .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", "StaffMemberId"); + + b.HasIndex("ProjectionOrdinal") + .IsUnique(); + + b.HasIndex("ScopeId", "IsRestricted", "StaffMemberId"); + + b.ToTable("staff_processing_restriction_state", "staff", t => + { + t.HasCheckConstraint("CK_staff_processing_restrictions_contract_version", "\"ContractVersion\" >= 1"); + + t.HasCheckConstraint("CK_staff_processing_restrictions_effective_state", "(\"ActiveRestrictionCount\" = 0 AND NOT \"IsRestricted\") OR (\"ActiveRestrictionCount\" > 0 AND \"IsRestricted\")"); + + t.HasCheckConstraint("CK_staff_processing_restrictions_revision", "\"Revision\" >= 0"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.DataRights.StaffProcessingRestrictionReceipt", 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("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("SelectedStaffVersion") + .HasColumnType("bigint"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("ScopeId", "CaseId", "ApprovalRevision"); + + b.HasIndex("ScopeId", "StaffMemberId", "CompletedAtUtc"); + + b.ToTable("staff_processing_restriction_receipts", "staff", t => + { + t.HasCheckConstraint("CK_staff_processing_restriction_receipts_versions", "\"ApprovalRevision\" >= 1 AND \"SelectedStaffVersion\" >= 1 AND \"ResultingProjectionRevision\" >= 1 AND ((\"Action\" = 1 AND \"ResultingRestrictionVersion\" = 1 AND \"EffectiveRestricted\") OR (\"Action\" = 2 AND \"ResultingRestrictionVersion\" >= 2))"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.Entities.StaffPropertyAssignment", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedAtVersion") + .HasColumnType("bigint"); + + b.Property("AssignedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EffectiveFrom") + .HasColumnType("date"); + + b.Property("EffectiveTo") + .HasColumnType("date"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("PropertyJobTitle") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UnassignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UnassignedAtVersion") + .HasColumnType("bigint"); + + b.Property("UnassignedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UnassignmentReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("ScopeId", "StaffMemberId", "Id"); + + b.HasIndex("ScopeId", "PropertyId", "IsCurrent", "StaffMemberId"); + + b.HasIndex("ScopeId", "StaffMemberId", "PropertyId", "IsCurrent"); + + b.ToTable("property_assignments", "staff", t => + { + t.HasCheckConstraint("CK_staff_assignments_dates", "\"EffectiveTo\" IS NULL OR \"EffectiveTo\" >= \"EffectiveFrom\""); + + t.HasCheckConstraint("CK_staff_assignments_lifecycle", "(\"IsCurrent\" AND \"EffectiveTo\" IS NULL AND \"UnassignedBy\" IS NULL AND \"UnassignedAtUtc\" IS NULL AND \"UnassignedAtVersion\" IS NULL) OR (NOT \"IsCurrent\" AND NOT \"IsPrimary\" AND \"EffectiveTo\" IS NOT NULL AND \"UnassignedBy\" IS NOT NULL AND \"UnassignedAtUtc\" IS NOT NULL AND \"UnassignedAtVersion\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_staff_assignments_versions", "\"AssignedAtVersion\" >= 2 AND (\"UnassignedAtVersion\" IS NULL OR \"UnassignedAtVersion\" >= \"AssignedAtVersion\")"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.Governance.StaffEmploymentGovernance", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConfiguredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConfiguredBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GovernanceContractVersion") + .HasColumnType("integer"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedStaffVersion") + .HasColumnType("bigint"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("staff_employment_governance", "staff", t => + { + t.HasCheckConstraint("CK_staff_employment_governance_contract", "\"GovernanceContractVersion\" = 1"); + + t.HasCheckConstraint("CK_staff_employment_governance_policy", "char_length(\"OperatingCountryCode\") = 2 AND \"PolicyVersion\" >= 1 AND \"RetentionPolicyVersion\" >= 1 AND char_length(\"PolicyContentSha256\") = 64 AND \"PolicyEffectiveAtUtc\" < \"PolicyExpiresAtUtc\" AND \"PolicyEvaluatedAtUtc\" >= \"PolicyEffectiveAtUtc\" AND \"PolicyEvaluatedAtUtc\" < \"PolicyExpiresAtUtc\" AND \"ConfiguredAtUtc\" >= \"PolicyEvaluatedAtUtc\" AND \"ConfiguredAtUtc\" < \"PolicyExpiresAtUtc\""); + + t.HasCheckConstraint("CK_staff_employment_governance_versions", "\"SelectedStaffVersion\" >= 1 AND \"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.Governance.StaffEmploymentGovernanceChangeReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcknowledgementsSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GovernanceContractVersion") + .HasColumnType("integer"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("PolicyContentSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PreviousGovernanceVersion") + .HasColumnType("bigint"); + + b.Property("ReceiptSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RequestSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ResultingGovernanceVersion") + .HasColumnType("bigint"); + + 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", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("ScopeId", "StaffMemberId", "CompletedAtUtc", "Id"); + + b.ToTable("staff_employment_governance_change_receipts", "staff", t => + { + t.HasCheckConstraint("CK_staff_employment_governance_receipts_contract", "\"GovernanceContractVersion\" = 1"); + + t.HasCheckConstraint("CK_staff_employment_governance_receipts_digests", "char_length(\"PolicyContentSha256\") = 64 AND char_length(\"AcknowledgementsSha256\") = 64 AND char_length(\"RequestSha256\") = 64 AND char_length(\"ReceiptSha256\") = 64"); + + t.HasCheckConstraint("CK_staff_employment_governance_receipts_versions", "\"SelectedStaffVersion\" >= 1 AND \"PreviousGovernanceVersion\" >= 0 AND \"ResultingGovernanceVersion\" = \"PreviousGovernanceVersion\" + 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.Retention.StaffRetentionAnonymisationReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + 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("DepartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("ExecutionId") + .HasColumnType("uuid"); + + b.Property("PolicyEvidenceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ResultingOperationLockRevision") + .HasColumnType("bigint"); + + b.Property("ResultingStaffVersion") + .HasColumnType("bigint"); + + b.Property("RetentionDeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedOperationLockRevision") + .HasColumnType("bigint"); + + b.Property("SelectedStaffVersion") + .HasColumnType("bigint"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "StaffMemberId") + .IsUnique(); + + b.HasIndex("ScopeId", "ExecutionId", "StaffMemberId") + .IsUnique(); + + b.ToTable("staff_retention_anonymisation_receipts", "staff", t => + { + t.HasCheckConstraint("CK_staff_retention_receipts_actor", "\"ActorId\" = 'system:retention'"); + + t.HasCheckConstraint("CK_staff_retention_receipts_contract", "\"ContractVersion\" = 1"); + + t.HasCheckConstraint("CK_staff_retention_receipts_deadline", "\"RetentionDeadlineUtc\" >= \"DepartedAtUtc\" AND \"CompletedAtUtc\" >= \"RetentionDeadlineUtc\""); + + t.HasCheckConstraint("CK_staff_retention_receipts_digests", "char_length(\"PolicyEvidenceSha256\") = 64 AND char_length(\"CanonicalSha256\") = 64"); + + t.HasCheckConstraint("CK_staff_retention_receipts_versions", "\"SelectedStaffVersion\" >= 1 AND \"ResultingStaffVersion\" = \"SelectedStaffVersion\" + 1 AND \"SelectedOperationLockRevision\" >= 1 AND \"ResultingOperationLockRevision\" = \"SelectedOperationLockRevision\" + 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.Retention.StaffRetentionExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedCount") + .HasColumnType("integer"); + + b.Property("Attempt") + .HasColumnType("integer"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DataClassKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutionPolicyVersion") + .HasColumnType("integer"); + + b.Property("HoldReviewDueAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OutcomeCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RemainingCount") + .HasColumnType("integer"); + + b.Property("ScannedCount") + .HasColumnType("integer"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StartingProjectionOrdinal") + .HasColumnType("bigint"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId", "DataClassKey", "CompletedAtUtc", "Id"); + + b.ToTable("staff_retention_executions", "staff", t => + { + t.HasCheckConstraint("CK_staff_retention_executions_counts", "\"AffectedCount\" >= 0 AND (\"ScannedCount\" IS NULL OR \"ScannedCount\" >= 0) AND (\"RemainingCount\" IS NULL OR \"RemainingCount\" >= 0) AND (\"ScannedCount\" IS NULL OR \"AffectedCount\" <= \"ScannedCount\")"); + + t.HasCheckConstraint("CK_staff_retention_executions_cursor", "\"StartingProjectionOrdinal\" >= 0"); + + t.HasCheckConstraint("CK_staff_retention_executions_key", "\"DataClassKey\" ~ '^[A-Za-z0-9.-]+$'"); + + t.HasCheckConstraint("CK_staff_retention_executions_policy", "\"ExecutionPolicyVersion\" >= 1 AND \"Attempt\" >= 1 AND \"DeadlineUtc\" > \"StartedAtUtc\""); + + t.HasCheckConstraint("CK_staff_retention_executions_state", "(\"State\" = 1 AND \"CompletedAtUtc\" IS NULL AND \"ScannedCount\" IS NULL AND \"RemainingCount\" IS NULL AND \"OutcomeCode\" IS NULL AND \"HoldReviewDueAtUtc\" IS NULL) OR (\"State\" IN (2, 3, 4) AND \"CompletedAtUtc\" BETWEEN \"StartedAtUtc\" AND \"DeadlineUtc\" AND \"ScannedCount\" IS NOT NULL AND \"RemainingCount\" IS NOT NULL AND \"OutcomeCode\" ~ '^[A-Za-z0-9.-]+$' AND ((\"State\" = 3 AND \"HoldReviewDueAtUtc\" IS NOT NULL) OR (\"State\" <> 3 AND \"HoldReviewDueAtUtc\" IS NULL)))"); + + t.HasCheckConstraint("CK_staff_retention_executions_version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.Retention.StaffRetentionSweepCheckpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AfterProjectionOrdinal") + .HasColumnType("bigint"); + + b.Property("DataClassKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExecutionPolicyVersion") + .HasColumnType("integer"); + + b.Property("LastExecutionId") + .HasColumnType("uuid"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "DataClassKey", "ExecutionPolicyVersion") + .IsUnique(); + + b.ToTable("staff_retention_sweep_checkpoints", "staff", t => + { + t.HasCheckConstraint("CK_staff_retention_checkpoints_cursor", "\"AfterProjectionOrdinal\" >= 0"); + + t.HasCheckConstraint("CK_staff_retention_checkpoints_key", "\"DataClassKey\" ~ '^[A-Za-z0-9.-]+$'"); + + t.HasCheckConstraint("CK_staff_retention_checkpoints_policy", "\"ExecutionPolicyVersion\" >= 1"); + + t.HasCheckConstraint("CK_staff_retention_checkpoints_version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.Models.StaffOperationLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "StaffMemberId") + .IsUnique(); + + b.ToTable("staff_operation_locks", "staff", t => + { + t.HasCheckConstraint("CK_staff_operation_locks_coordinate", "\"Id\" = \"StaffMemberId\""); + + t.HasCheckConstraint("CK_staff_operation_locks_revision", "\"Revision\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceKind") + .HasColumnType("integer"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("AnchoredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolutionEventId") + .HasColumnType("uuid"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.HasKey("ScopeId", "SourceKind", "SourceId"); + + b.HasAlternateKey("ScopeId", "SourceKind", "SourceId", "StaffMemberId"); + + b.HasIndex("ResolutionEventId") + .IsUnique() + .HasFilter("\"ResolutionEventId\" IS NOT NULL"); + + b.HasIndex("ScopeId", "StaffMemberId", "SourceKind", "SourceId"); + + b.HasIndex("ScopeId", "SourceKind", "SourceId", "StaffMemberId", "ResolutionEventId") + .IsUnique() + .HasFilter("\"ResolutionEventId\" IS NOT NULL"); + + b.ToTable("identity_provisioning_anchors", "staff", t => + { + t.HasCheckConstraint("CK_staff_identity_provisioning_anchors_ids", "\"SourceId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"StaffMemberId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_staff_identity_provisioning_anchors_resolution_event", "(\"SourceKind\" = 1 AND \"ResolutionEventId\" IS NOT NULL AND \"ResolutionEventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ResolutionEventId\" <> \"SourceId\") OR (\"SourceKind\" = 2 AND \"ResolutionEventId\" IS NULL)"); + + t.HasCheckConstraint("CK_staff_identity_provisioning_anchors_source_kind", "\"SourceKind\" IN (1, 2)"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceKind") + .HasColumnType("integer"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("Disposition") + .HasColumnType("integer"); + + b.Property("ResolutionEventId") + .HasColumnType("uuid"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.Property("WorkspaceApplicationVersion") + .HasColumnType("bigint"); + + b.HasKey("ScopeId", "SourceKind", "SourceId"); + + b.HasIndex("ResolutionEventId") + .IsUnique(); + + b.HasIndex("ScopeId", "StaffMemberId", "SourceId"); + + b.HasIndex("ScopeId", "SourceKind", "SourceId", "StaffMemberId"); + + b.ToTable("identity_provisioning_anchor_resolutions", "staff", t => + { + t.HasTrigger("TR_staff_identity_provisioning_anchor_resolution_integrity"); + + t.HasCheckConstraint("CK_staff_identity_provisioning_anchor_resolutions_evidence", "\"WorkspaceApplicationVersion\" >= 1 AND \"Disposition\" BETWEEN 1 AND 5"); + + t.HasCheckConstraint("CK_staff_identity_provisioning_anchor_resolutions_ids", "\"SourceId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"StaffMemberId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ResolutionEventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ResolutionEventId\" <> \"SourceId\""); + + t.HasCheckConstraint("CK_staff_identity_provisioning_anchor_resolutions_source", "\"SourceKind\" = 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.StaffMemberMutationOperation", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedVersion") + .HasColumnType("bigint"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("RequestFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ResultStatus") + .HasColumnType("integer"); + + b.Property("ResultVersion") + .HasColumnType("bigint"); + + b.HasKey("ScopeId", "StaffMemberId", "Id"); + + b.HasIndex("ScopeId", "Id") + .IsUnique() + .HasDatabaseName("UX_staff_member_mutation_operations_onboarding_operation") + .HasFilter("\"Kind\" = 8"); + + b.HasIndex("ScopeId", "CompletedAtUtc", "Id"); + + b.ToTable("member_mutation_operations", "staff", t => + { + t.HasCheckConstraint("CK_staff_member_mutation_operations_fingerprint", "char_length(\"RequestFingerprint\") = 64 AND \"RequestFingerprint\" ~ '^[0-9a-f]{64}$'"); + + t.HasCheckConstraint("CK_staff_member_mutation_operations_kind", "\"Kind\" IN (1, 2, 3, 4, 5, 6, 7, 8)"); + + t.HasCheckConstraint("CK_staff_member_mutation_operations_status", "\"ResultStatus\" IN (1, 2, 3) AND (\"Kind\" <> 8 OR \"ResultStatus\" = 1)"); + + t.HasCheckConstraint("CK_staff_member_mutation_operations_versions", "\"ExpectedVersion\" > 0 AND \"ResultVersion\" >= \"ExpectedVersion\" AND \"ResultVersion\" <= \"ExpectedVersion\" + CASE WHEN \"Kind\" = 8 THEN 2 ELSE 1 END"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.StaffProjectionRebuildCheckpoint", 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", "staff"); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.StaffPropertyProjection", 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", "staff", t => + { + t.HasCheckConstraint("CK_staff_property_projection_version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.StaffTenantRevision", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DestroyCompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DestroyOperationId") + .HasColumnType("uuid"); + + b.Property("DestroyRequestSha256") + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("DestroyStartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LifecycleStatus") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("ScopeId"); + + b.ToTable("tenant_revisions", "staff", t => + { + t.HasCheckConstraint("CK_staff_tenant_revision_lifecycle", "(\"LifecycleStatus\" = 1 AND \"DestroyOperationId\" IS NULL AND \"DestroyRequestSha256\" IS NULL AND \"DestroyStartedAtUtc\" IS NULL AND \"DestroyCompletedAtUtc\" IS NULL) OR (\"LifecycleStatus\" = 2 AND \"DestroyOperationId\" IS NOT NULL AND \"DestroyRequestSha256\" IS NOT NULL AND \"DestroyStartedAtUtc\" IS NOT NULL AND \"DestroyCompletedAtUtc\" IS NULL) OR (\"LifecycleStatus\" = 3 AND \"DestroyOperationId\" IS NOT NULL AND \"DestroyRequestSha256\" IS NOT NULL AND \"DestroyStartedAtUtc\" IS NOT NULL AND \"DestroyCompletedAtUtc\" >= \"DestroyStartedAtUtc\")"); + + t.HasCheckConstraint("CK_staff_tenant_revision_positive", "\"Revision\" > 0"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.TenantTermination.StaffTenantDestroyOperation", b => + { + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("BatchSize") + .HasColumnType("integer"); + + b.Property("CompletedBatchCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyVersion") + .IsConcurrencyToken() + .HasColumnType("integer"); + + 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("ResultingRevision") + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedRevision") + .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.ToTable("tenant_destroy_operations", "staff", t => + { + t.HasCheckConstraint("CK_staff_tenant_destroy_operation_batch", "\"BatchSize\" BETWEEN 1 AND 500"); + + t.HasCheckConstraint("CK_staff_tenant_destroy_operation_progress", "\"Stage\" BETWEEN 1 AND 25 AND \"RemovedRecordCount\" >= 0 AND \"CompletedBatchCount\" >= 0 AND \"ProofVersion\" = 1 AND \"ConcurrencyVersion\" >= 1"); + + t.HasCheckConstraint("CK_staff_tenant_destroy_operation_revisions", "\"SelectedRevision\" >= 0 AND \"ResultingRevision\" = \"SelectedRevision\" + 1"); + + t.HasCheckConstraint("CK_staff_tenant_destroy_operation_times", "\"UpdatedAtUtc\" >= \"StartedAtUtc\""); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.TenantTermination.StaffTenantDestroyReceipt", b => + { + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("BatchSize") + .HasColumnType("integer"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletedBatchCount") + .HasColumnType("integer"); + + 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("ResultingRevision") + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedRevision") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("OperationId"); + + b.HasIndex("ScopeId") + .IsUnique(); + + b.ToTable("tenant_destroy_receipts", "staff", t => + { + t.HasCheckConstraint("CK_staff_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_staff_tenant_destroy_receipt_revisions", "\"SelectedRevision\" >= 0 AND \"ResultingRevision\" = \"SelectedRevision\" + 1"); + + t.HasCheckConstraint("CK_staff_tenant_destroy_receipt_times", "\"CompletedAtUtc\" >= \"StartedAtUtc\""); + }); + }); + + 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", "staff"); + }); + + 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", "staff"); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.DataRights.StaffAnonymisationReceipt", b => + { + b.HasOne("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", null) + .WithMany() + .HasForeignKey("ScopeId", "StaffMemberId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.DataRights.StaffAnonymisationRestoreReceipt", b => + { + b.HasOne("BunkFy.Modules.Staff.Domain.DataRights.StaffAnonymisationTombstone", null) + .WithMany() + .HasForeignKey("ScopeId", "StaffMemberId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.DataRights.StaffAnonymisationTombstone", b => + { + b.HasOne("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", null) + .WithOne() + .HasForeignKey("BunkFy.Modules.Staff.Domain.DataRights.StaffAnonymisationTombstone", "ScopeId", "Id") + .HasPrincipalKey("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", "ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.DataRights.StaffDataHold", b => + { + b.HasOne("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", null) + .WithMany() + .HasForeignKey("ScopeId", "StaffMemberId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.DataRights.StaffDataHoldReceipt", b => + { + b.HasOne("BunkFy.Modules.Staff.Domain.DataRights.StaffDataHold", null) + .WithMany() + .HasForeignKey("ScopeId", "HoldId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.Entities.StaffPropertyAssignment", b => + { + b.HasOne("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", null) + .WithMany("Assignments") + .HasForeignKey("ScopeId", "StaffMemberId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.Governance.StaffEmploymentGovernance", b => + { + b.HasOne("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", null) + .WithOne() + .HasForeignKey("BunkFy.Modules.Staff.Domain.Governance.StaffEmploymentGovernance", "ScopeId", "Id") + .HasPrincipalKey("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", "ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("BunkFy.Modules.Staff.Domain.Governance.StaffEmploymentGovernanceBinding", "Binding", b1 => + { + b1.Property("StaffEmploymentGovernanceId") + .HasColumnType("uuid"); + + b1.Property("ContentSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("PolicyContentSha256"); + + b1.Property("DataRegionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("DataRegionId"); + + b1.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("PolicyEvaluatedAtUtc"); + + b1.Property("OperatingCountryCode") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("OperatingCountryCode"); + + b1.Property("PolicyEffectiveAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("PolicyEffectiveAtUtc"); + + b1.Property("PolicyExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("PolicyExpiresAtUtc"); + + b1.Property("PolicyId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("PolicyId"); + + b1.Property("PolicyVersion") + .HasColumnType("integer") + .HasColumnName("PolicyVersion"); + + b1.Property("RetentionPolicyId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("RetentionPolicyId"); + + b1.Property("RetentionPolicyVersion") + .HasColumnType("integer") + .HasColumnName("RetentionPolicyVersion"); + + b1.Property("TransferProfileId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("TransferProfileId"); + + b1.HasKey("StaffEmploymentGovernanceId"); + + b1.ToTable("staff_employment_governance", "staff"); + + b1.WithOwner() + .HasForeignKey("StaffEmploymentGovernanceId"); + }); + + b.OwnsMany("BunkFy.Modules.Staff.Domain.Governance.StaffEmploymentGovernanceAcknowledgement", "AcceptedAcknowledgements", b1 => + { + b1.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b1.Property("StaffMemberId") + .HasColumnType("uuid"); + + b1.Property("AcknowledgementId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b1.Property("AcknowledgementVersion") + .HasColumnType("integer"); + + b1.HasKey("ScopeId", "StaffMemberId", "AcknowledgementId", "AcknowledgementVersion"); + + b1.HasIndex("ScopeId", "StaffMemberId"); + + b1.ToTable("staff_employment_governance_acknowledgements", "staff"); + + b1.WithOwner() + .HasForeignKey("ScopeId", "StaffMemberId") + .HasPrincipalKey("ScopeId", "Id"); + }); + + b.Navigation("AcceptedAcknowledgements"); + + b.Navigation("Binding") + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.Retention.StaffRetentionAnonymisationReceipt", b => + { + b.HasOne("BunkFy.Modules.Staff.Domain.Retention.StaffRetentionExecution", null) + .WithMany() + .HasForeignKey("ScopeId", "ExecutionId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", null) + .WithOne() + .HasForeignKey("BunkFy.Modules.Staff.Domain.Retention.StaffRetentionAnonymisationReceipt", "ScopeId", "StaffMemberId") + .HasPrincipalKey("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", "ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.Models.StaffOperationLock", b => + { + b.HasOne("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", null) + .WithOne() + .HasForeignKey("BunkFy.Modules.Staff.Persistence.Models.StaffOperationLock", "ScopeId", "StaffMemberId") + .HasPrincipalKey("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", "ScopeId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor", b => + { + b.HasOne("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", null) + .WithMany() + .HasForeignKey("ScopeId", "StaffMemberId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution", b => + { + b.HasOne("BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor", null) + .WithMany() + .HasForeignKey("ScopeId", "SourceKind", "SourceId", "StaffMemberId") + .HasPrincipalKey("ScopeId", "SourceKind", "SourceId", "StaffMemberId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.StaffMemberMutationOperation", b => + { + b.HasOne("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", null) + .WithMany() + .HasForeignKey("ScopeId", "StaffMemberId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", b => + { + b.Navigation("Assignments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence.PostgreSqlMigrations/Migrations/20260811110753_AddStaffIdentityProvisioningAnchors.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence.PostgreSqlMigrations/Migrations/20260811110753_AddStaffIdentityProvisioningAnchors.cs new file mode 100644 index 00000000..7c530b2b --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence.PostgreSqlMigrations/Migrations/20260811110753_AddStaffIdentityProvisioningAnchors.cs @@ -0,0 +1,502 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BunkFy.Modules.Staff.Persistence.PostgreSqlMigrations.Migrations +{ + /// + public partial class AddStaffIdentityProvisioningAnchors : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + LOCK TABLE "staff"."tenant_revisions" IN SHARE MODE NOWAIT; + LOCK TABLE "staff"."member_mutation_operations" IN SHARE MODE NOWAIT; + LOCK TABLE "staff"."staff_members" IN SHARE MODE NOWAIT; + + DO $preflight$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM "staff"."tenant_revisions" state + WHERE state."LifecycleStatus" = 2) + THEN + RAISE EXCEPTION + 'Cannot install Staff identity provisioning anchors while a tenant destruction is in progress'; + END IF; + END; + $preflight$; + """); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_tenant_destroy_operation_progress", + schema: "staff", + table: "tenant_destroy_operations"); + + migrationBuilder.CreateTable( + name: "identity_provisioning_anchors", + schema: "staff", + columns: table => new + { + ScopeId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + SourceKind = table.Column(type: "integer", nullable: false), + SourceId = table.Column(type: "uuid", nullable: false), + StaffMemberId = table.Column(type: "uuid", nullable: false), + AnchoredAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ResolutionEventId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_identity_provisioning_anchors", x => new { x.ScopeId, x.SourceKind, x.SourceId }); + table.CheckConstraint("CK_staff_identity_provisioning_anchors_ids", "\"SourceId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"StaffMemberId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + table.CheckConstraint("CK_staff_identity_provisioning_anchors_resolution_event", "(\"SourceKind\" = 1 AND \"ResolutionEventId\" IS NOT NULL AND \"ResolutionEventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ResolutionEventId\" <> \"SourceId\") OR (\"SourceKind\" = 2 AND \"ResolutionEventId\" IS NULL)"); + table.CheckConstraint("CK_staff_identity_provisioning_anchors_source_kind", "\"SourceKind\" IN (1, 2)"); + table.ForeignKey( + name: "FK_identity_provisioning_anchors_staff_members_ScopeId_StaffMe~", + columns: x => new { x.ScopeId, x.StaffMemberId }, + principalSchema: "staff", + principalTable: "staff_members", + principalColumns: new[] { "ScopeId", "Id" }, + onDelete: ReferentialAction.Restrict); + table.UniqueConstraint( + "AK_identity_provisioning_anchors_ScopeId_SourceKind_SourceId_S~", + x => new { x.ScopeId, x.SourceKind, x.SourceId, x.StaffMemberId }); + }); + + migrationBuilder.CreateTable( + name: "identity_provisioning_anchor_resolutions", + schema: "staff", + columns: table => new + { + ScopeId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + SourceKind = table.Column(type: "integer", nullable: false), + SourceId = table.Column(type: "uuid", nullable: false), + StaffMemberId = table.Column(type: "uuid", nullable: false), + WorkspaceApplicationVersion = table.Column(type: "bigint", nullable: false), + Disposition = table.Column(type: "integer", nullable: false), + ResolutionEventId = table.Column(type: "uuid", nullable: false), + ResolvedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_identity_provisioning_anchor_resolutions", x => new { x.ScopeId, x.SourceKind, x.SourceId }); + table.CheckConstraint("CK_staff_identity_provisioning_anchor_resolutions_evidence", "\"WorkspaceApplicationVersion\" >= 1 AND \"Disposition\" BETWEEN 1 AND 5"); + table.CheckConstraint("CK_staff_identity_provisioning_anchor_resolutions_ids", "\"SourceId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"StaffMemberId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ResolutionEventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ResolutionEventId\" <> \"SourceId\""); + table.CheckConstraint("CK_staff_identity_provisioning_anchor_resolutions_source", "\"SourceKind\" = 1"); + table.ForeignKey( + name: "FK_identity_provisioning_anchor_resolutions_identity_provision~", + columns: x => new { x.ScopeId, x.SourceKind, x.SourceId, x.StaffMemberId }, + principalSchema: "staff", + principalTable: "identity_provisioning_anchors", + principalColumns: new[] { "ScopeId", "SourceKind", "SourceId", "StaffMemberId" }, + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_tenant_destroy_operation_progress", + schema: "staff", + table: "tenant_destroy_operations", + sql: "\"Stage\" BETWEEN 1 AND 25 AND \"RemovedRecordCount\" >= 0 AND \"CompletedBatchCount\" >= 0 AND \"ProofVersion\" = 1 AND \"ConcurrencyVersion\" >= 1"); + + migrationBuilder.CreateIndex( + name: "IX_identity_provisioning_anchors_ResolutionEventId", + schema: "staff", + table: "identity_provisioning_anchors", + column: "ResolutionEventId", + unique: true, + filter: "\"ResolutionEventId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_identity_provisioning_anchors_ScopeId_StaffMemberId_SourceK~", + schema: "staff", + table: "identity_provisioning_anchors", + columns: new[] { "ScopeId", "StaffMemberId", "SourceKind", "SourceId" }); + + migrationBuilder.CreateIndex( + name: "IX_identity_provisioning_anchors_ScopeId_SourceKind_SourceId_~", + schema: "staff", + table: "identity_provisioning_anchors", + columns: new[] { "ScopeId", "SourceKind", "SourceId", "StaffMemberId", "ResolutionEventId" }, + unique: true, + filter: "\"ResolutionEventId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_identity_provisioning_anchor_resolutions_ResolutionEventId", + schema: "staff", + table: "identity_provisioning_anchor_resolutions", + column: "ResolutionEventId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_identity_provisioning_anchor_resolutions_ScopeId_SourceKind~", + schema: "staff", + table: "identity_provisioning_anchor_resolutions", + columns: new[] { "ScopeId", "SourceKind", "SourceId", "StaffMemberId" }); + + migrationBuilder.CreateIndex( + name: "IX_identity_provisioning_anchor_resolutions_ScopeId_StaffMembe~", + schema: "staff", + table: "identity_provisioning_anchor_resolutions", + columns: new[] { "ScopeId", "StaffMemberId", "SourceId" }); + + migrationBuilder.Sql( + """ + INSERT INTO "staff"."identity_provisioning_anchors" + ("ScopeId", "SourceKind", "SourceId", + "StaffMemberId", "AnchoredAtUtc", + "ResolutionEventId") + SELECT operation."ScopeId", + 1, + operation."Id", + operation."StaffMemberId", + operation."CompletedAtUtc", + gen_random_uuid() + FROM "staff"."member_mutation_operations" operation + LEFT JOIN "staff"."tenant_revisions" state + ON state."ScopeId" = operation."ScopeId" + WHERE operation."Kind" = 8 + AND COALESCE(state."LifecycleStatus", 1) = 1 + ORDER BY operation."ScopeId", operation."Id"; + + CREATE FUNCTION + "staff".prevent_identity_provisioning_anchor_mutation() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + destroy_operation_id text; + destroy_stage text; + BEGIN + destroy_operation_id := current_setting( + 'bunkfy.staff_tenant_destroy_operation_id', + true); + destroy_stage := current_setting( + 'bunkfy.staff_tenant_destroy_stage', + true); + IF TG_OP = 'DELETE' AND + destroy_operation_id IS NOT NULL AND + destroy_stage = '24' AND + EXISTS ( + SELECT 1 + FROM "staff".tenant_destroy_operations operation + INNER JOIN "staff".tenant_revisions state + ON state."ScopeId" = operation."ScopeId" + WHERE operation."OperationId"::text = + destroy_operation_id + AND operation."ScopeId" = OLD."ScopeId" + AND operation."Stage" IN (17, 24) + AND state."LifecycleStatus" = 2 + AND state."DestroyOperationId" = + operation."OperationId" + AND state."DestroyRequestSha256" = + operation."RequestSha256") + THEN + RETURN OLD; + END IF; + + RAISE EXCEPTION + 'Staff identity provisioning anchors are immutable'; + END; + $function$; + + CREATE TRIGGER + "TR_staff_identity_provisioning_anchors_immutable" + BEFORE UPDATE OR DELETE + ON "staff"."identity_provisioning_anchors" + FOR EACH ROW + EXECUTE FUNCTION + "staff".prevent_identity_provisioning_anchor_mutation(); + + CREATE FUNCTION + "staff".prevent_identity_provisioning_anchor_resolution_mutation() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + destroy_operation_id text; + destroy_stage text; + BEGIN + destroy_operation_id := current_setting( + 'bunkfy.staff_tenant_destroy_operation_id', + true); + destroy_stage := current_setting( + 'bunkfy.staff_tenant_destroy_stage', + true); + IF TG_OP = 'DELETE' AND + destroy_operation_id IS NOT NULL AND + destroy_stage = '25' AND + EXISTS ( + SELECT 1 + FROM "staff".tenant_destroy_operations operation + INNER JOIN "staff".tenant_revisions state + ON state."ScopeId" = operation."ScopeId" + WHERE operation."OperationId"::text = + destroy_operation_id + AND operation."ScopeId" = OLD."ScopeId" + AND operation."Stage" IN (24, 25) + AND state."LifecycleStatus" = 2 + AND state."DestroyOperationId" = + operation."OperationId" + AND state."DestroyRequestSha256" = + operation."RequestSha256") + THEN + RETURN OLD; + END IF; + + RAISE EXCEPTION + 'Staff identity provisioning anchor resolutions are immutable'; + END; + $function$; + + CREATE TRIGGER + "TR_staff_identity_provisioning_anchor_resolutions_immutable" + BEFORE UPDATE OR DELETE + ON "staff"."identity_provisioning_anchor_resolutions" + FOR EACH ROW + EXECUTE FUNCTION + "staff".prevent_identity_provisioning_anchor_resolution_mutation(); + + CREATE FUNCTION + "staff".enforce_identity_provisioning_anchor_resolution_integrity() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM "staff".identity_provisioning_anchors anchor + WHERE anchor."ScopeId" = NEW."ScopeId" + AND anchor."SourceKind" = NEW."SourceKind" + AND anchor."SourceId" = NEW."SourceId" + AND anchor."StaffMemberId" = NEW."StaffMemberId" + AND anchor."ResolutionEventId" = + NEW."ResolutionEventId" + AND NEW."ResolvedAtUtc" >= anchor."AnchoredAtUtc") + THEN + RAISE EXCEPTION + 'Staff identity provisioning anchor resolution does not match its exact anchor coordinates'; + END IF; + + RETURN NULL; + END; + $function$; + + CREATE CONSTRAINT TRIGGER + "TR_staff_identity_provisioning_anchor_resolution_integrity" + AFTER INSERT + ON "staff"."identity_provisioning_anchor_resolutions" + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE FUNCTION + "staff".enforce_identity_provisioning_anchor_resolution_integrity(); + + CREATE FUNCTION + "staff".enforce_workspace_onboarding_receipt_anchor() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + IF TG_OP IN ('INSERT', 'UPDATE') AND NEW."Kind" = 8 AND + NOT EXISTS ( + SELECT 1 + FROM "staff".identity_provisioning_anchors anchor + WHERE anchor."ScopeId" = NEW."ScopeId" + AND anchor."SourceKind" = 1 + AND anchor."SourceId" = NEW."Id" + AND anchor."StaffMemberId" = NEW."StaffMemberId") + THEN + RAISE EXCEPTION + 'Workspace onboarding receipt requires an exact Staff identity provisioning anchor'; + END IF; + + IF (TG_OP = 'DELETE' OR + (TG_OP = 'UPDATE' AND + (NEW."Kind" <> 8 OR + NEW."ScopeId" <> OLD."ScopeId" OR + NEW."Id" <> OLD."Id" OR + NEW."StaffMemberId" <> OLD."StaffMemberId"))) AND + OLD."Kind" = 8 AND + EXISTS ( + SELECT 1 + FROM "staff".identity_provisioning_anchors anchor + WHERE anchor."ScopeId" = OLD."ScopeId" + AND anchor."SourceKind" = 1 + AND anchor."SourceId" = OLD."Id" + AND anchor."StaffMemberId" = OLD."StaffMemberId" + AND NOT EXISTS ( + SELECT 1 + FROM "staff".identity_provisioning_anchor_resolutions resolution + WHERE resolution."ScopeId" = anchor."ScopeId" + AND resolution."SourceKind" = anchor."SourceKind" + AND resolution."SourceId" = anchor."SourceId" + AND resolution."StaffMemberId" = anchor."StaffMemberId" + AND resolution."ResolutionEventId" = + anchor."ResolutionEventId")) + THEN + RAISE EXCEPTION + 'Unresolved Workspace onboarding receipt cannot be removed'; + END IF; + + RETURN NULL; + END; + $function$; + + CREATE CONSTRAINT TRIGGER + "TR_staff_workspace_onboarding_receipt_anchor" + AFTER INSERT OR UPDATE OR DELETE + ON "staff"."member_mutation_operations" + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE FUNCTION + "staff".enforce_workspace_onboarding_receipt_anchor(); + + CREATE FUNCTION + "staff".prevent_unresolved_workspace_anchor_identity_lifecycle() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + IF ((NEW."AuthSubjectId" IS DISTINCT FROM OLD."AuthSubjectId" AND + NOT (OLD."Status" <> 4 AND + NEW."Status" = 4 AND + NEW."AuthSubjectId" IS NULL)) OR + (NEW."Status" = 4 AND + OLD."Status" <> 4 AND + NEW."AuthSubjectId" IS NOT NULL)) AND + EXISTS ( + SELECT 1 + FROM "staff".identity_provisioning_anchors anchor + WHERE anchor."ScopeId" = OLD."ScopeId" + AND anchor."SourceKind" = 1 + AND anchor."StaffMemberId" = OLD."Id") + THEN + RAISE EXCEPTION + 'Workspace onboarding anchor requires access closure before Staff Auth subject change'; + END IF; + + IF ((NEW."AuthSubjectId" IS DISTINCT FROM OLD."AuthSubjectId" AND + NEW."Status" = 4) OR + (NEW."Status" = 4 AND OLD."Status" <> 4) OR + (NEW."Status" = 1 AND OLD."Status" <> 1)) AND + EXISTS ( + SELECT 1 + FROM "staff".identity_provisioning_anchors anchor + WHERE anchor."ScopeId" = OLD."ScopeId" + AND anchor."SourceKind" = 1 + AND anchor."StaffMemberId" = OLD."Id" + AND NOT EXISTS ( + SELECT 1 + FROM "staff".identity_provisioning_anchor_resolutions resolution + WHERE resolution."ScopeId" = anchor."ScopeId" + AND resolution."SourceKind" = anchor."SourceKind" + AND resolution."SourceId" = anchor."SourceId" + AND resolution."StaffMemberId" = anchor."StaffMemberId" + AND resolution."ResolutionEventId" = + anchor."ResolutionEventId")) + THEN + RAISE EXCEPTION + 'Unresolved Workspace onboarding anchor blocks Staff identity lifecycle mutation'; + END IF; + + RETURN NEW; + END; + $function$; + + CREATE TRIGGER + "TR_staff_members_unresolved_workspace_anchor_identity_lifecycle" + BEFORE UPDATE OF "AuthSubjectId", "Status" + ON "staff"."staff_members" + FOR EACH ROW + EXECUTE FUNCTION + "staff".prevent_unresolved_workspace_anchor_identity_lifecycle(); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + LOCK TABLE "staff"."tenant_revisions" IN SHARE MODE; + LOCK TABLE "staff"."tenant_destroy_operations" IN SHARE MODE; + LOCK TABLE "staff"."member_mutation_operations" + IN ACCESS EXCLUSIVE MODE; + LOCK TABLE "staff"."identity_provisioning_anchor_resolutions" + IN ACCESS EXCLUSIVE MODE; + LOCK TABLE "staff"."identity_provisioning_anchors" + IN ACCESS EXCLUSIVE MODE; + + DO $guard$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM "staff"."identity_provisioning_anchor_resolutions") OR + EXISTS ( + SELECT 1 + FROM "staff"."identity_provisioning_anchors") + THEN + RAISE EXCEPTION + 'Cannot remove Staff identity provisioning anchors while durable anchors exist'; + END IF; + END; + $guard$; + + DROP TRIGGER IF EXISTS + "TR_staff_members_unresolved_workspace_anchor_identity_lifecycle" + ON "staff"."staff_members"; + DROP FUNCTION IF EXISTS + "staff".prevent_unresolved_workspace_anchor_identity_lifecycle(); + + DROP TRIGGER IF EXISTS + "TR_staff_workspace_onboarding_receipt_anchor" + ON "staff"."member_mutation_operations"; + DROP FUNCTION IF EXISTS + "staff".enforce_workspace_onboarding_receipt_anchor(); + + DROP TRIGGER IF EXISTS + "TR_staff_identity_provisioning_anchor_resolution_integrity" + ON "staff"."identity_provisioning_anchor_resolutions"; + DROP FUNCTION IF EXISTS + "staff".enforce_identity_provisioning_anchor_resolution_integrity(); + + DROP TRIGGER IF EXISTS + "TR_staff_identity_provisioning_anchor_resolutions_immutable" + ON "staff"."identity_provisioning_anchor_resolutions"; + DROP FUNCTION IF EXISTS + "staff".prevent_identity_provisioning_anchor_resolution_mutation(); + + DROP TRIGGER IF EXISTS + "TR_staff_identity_provisioning_anchors_immutable" + ON "staff"."identity_provisioning_anchors"; + DROP FUNCTION IF EXISTS + "staff".prevent_identity_provisioning_anchor_mutation(); + + UPDATE "staff"."tenant_destroy_operations" + SET "Stage" = 17 + WHERE "Stage" IN (24, 25); + """); + + migrationBuilder.DropTable( + name: "identity_provisioning_anchor_resolutions", + schema: "staff"); + + migrationBuilder.DropTable( + name: "identity_provisioning_anchors", + schema: "staff"); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_tenant_destroy_operation_progress", + schema: "staff", + table: "tenant_destroy_operations"); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_tenant_destroy_operation_progress", + schema: "staff", + table: "tenant_destroy_operations", + sql: "\"Stage\" BETWEEN 1 AND 23 AND \"RemovedRecordCount\" >= 0 AND \"CompletedBatchCount\" >= 0 AND \"ProofVersion\" = 1 AND \"ConcurrencyVersion\" >= 1"); + } + } +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence.PostgreSqlMigrations/Migrations/StaffDbContextModelSnapshot.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence.PostgreSqlMigrations/Migrations/StaffDbContextModelSnapshot.cs index f9e46162..d675caf6 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence.PostgreSqlMigrations/Migrations/StaffDbContextModelSnapshot.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence.PostgreSqlMigrations/Migrations/StaffDbContextModelSnapshot.cs @@ -1211,6 +1211,99 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceKind") + .HasColumnType("integer"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("AnchoredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolutionEventId") + .HasColumnType("uuid"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.HasKey("ScopeId", "SourceKind", "SourceId"); + + b.HasAlternateKey("ScopeId", "SourceKind", "SourceId", "StaffMemberId"); + + b.HasIndex("ResolutionEventId") + .IsUnique() + .HasFilter("\"ResolutionEventId\" IS NOT NULL"); + + b.HasIndex("ScopeId", "StaffMemberId", "SourceKind", "SourceId"); + + b.HasIndex("ScopeId", "SourceKind", "SourceId", "StaffMemberId", "ResolutionEventId") + .IsUnique() + .HasFilter("\"ResolutionEventId\" IS NOT NULL"); + + b.ToTable("identity_provisioning_anchors", "staff", t => + { + t.HasCheckConstraint("CK_staff_identity_provisioning_anchors_ids", "\"SourceId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"StaffMemberId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_staff_identity_provisioning_anchors_resolution_event", "(\"SourceKind\" = 1 AND \"ResolutionEventId\" IS NOT NULL AND \"ResolutionEventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ResolutionEventId\" <> \"SourceId\") OR (\"SourceKind\" = 2 AND \"ResolutionEventId\" IS NULL)"); + + t.HasCheckConstraint("CK_staff_identity_provisioning_anchors_source_kind", "\"SourceKind\" IN (1, 2)"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceKind") + .HasColumnType("integer"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("Disposition") + .HasColumnType("integer"); + + b.Property("ResolutionEventId") + .HasColumnType("uuid"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StaffMemberId") + .HasColumnType("uuid"); + + b.Property("WorkspaceApplicationVersion") + .HasColumnType("bigint"); + + b.HasKey("ScopeId", "SourceKind", "SourceId"); + + b.HasIndex("ResolutionEventId") + .IsUnique(); + + b.HasIndex("ScopeId", "StaffMemberId", "SourceId"); + + b.HasIndex("ScopeId", "SourceKind", "SourceId", "StaffMemberId"); + + b.ToTable("identity_provisioning_anchor_resolutions", "staff", t => + { + t.HasTrigger("TR_staff_identity_provisioning_anchor_resolution_integrity"); + + t.HasCheckConstraint("CK_staff_identity_provisioning_anchor_resolutions_evidence", "\"WorkspaceApplicationVersion\" >= 1 AND \"Disposition\" BETWEEN 1 AND 5"); + + t.HasCheckConstraint("CK_staff_identity_provisioning_anchor_resolutions_ids", "\"SourceId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"StaffMemberId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ResolutionEventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ResolutionEventId\" <> \"SourceId\""); + + t.HasCheckConstraint("CK_staff_identity_provisioning_anchor_resolutions_source", "\"SourceKind\" = 1"); + }); + }); + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.StaffMemberMutationOperation", b => { b.Property("ScopeId") @@ -1438,7 +1531,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { t.HasCheckConstraint("CK_staff_tenant_destroy_operation_batch", "\"BatchSize\" BETWEEN 1 AND 500"); - t.HasCheckConstraint("CK_staff_tenant_destroy_operation_progress", "\"Stage\" BETWEEN 1 AND 23 AND \"RemovedRecordCount\" >= 0 AND \"CompletedBatchCount\" >= 0 AND \"ProofVersion\" = 1 AND \"ConcurrencyVersion\" >= 1"); + t.HasCheckConstraint("CK_staff_tenant_destroy_operation_progress", "\"Stage\" BETWEEN 1 AND 25 AND \"RemovedRecordCount\" >= 0 AND \"CompletedBatchCount\" >= 0 AND \"ProofVersion\" = 1 AND \"ConcurrencyVersion\" >= 1"); t.HasCheckConstraint("CK_staff_tenant_destroy_operation_revisions", "\"SelectedRevision\" >= 0 AND \"ResultingRevision\" = \"SelectedRevision\" + 1"); @@ -1830,6 +1923,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor", b => + { + b.HasOne("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", null) + .WithMany() + .HasForeignKey("ScopeId", "StaffMemberId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution", b => + { + b.HasOne("BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor", null) + .WithMany() + .HasForeignKey("ScopeId", "SourceKind", "SourceId", "StaffMemberId") + .HasPrincipalKey("ScopeId", "SourceKind", "SourceId", "StaffMemberId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("BunkFy.Modules.Staff.Persistence.StaffMemberMutationOperation", b => { b.HasOne("BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", null) diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Configurations/StaffIdentityProvisioningAnchorConfiguration.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Configurations/StaffIdentityProvisioningAnchorConfiguration.cs new file mode 100644 index 00000000..daaf48ef --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Configurations/StaffIdentityProvisioningAnchorConfiguration.cs @@ -0,0 +1,88 @@ +namespace BunkFy.Modules.Staff.Persistence.Configurations; + +using BunkFy.Modules.Staff.Application.Ports; +using BunkFy.Modules.Staff.Domain.Aggregates; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +internal sealed class StaffIdentityProvisioningAnchorConfiguration + : IEntityTypeConfiguration +{ + public void Configure( + EntityTypeBuilder builder) + { + builder.ToTable("identity_provisioning_anchors", table => + { + table.HasCheckConstraint( + "CK_staff_identity_provisioning_anchors_source_kind", + $"\"SourceKind\" IN (" + + $"{(int)StaffIdentityProvisioningSourceKind.WorkspaceOnboarding}, " + + $"{(int)StaffIdentityProvisioningSourceKind.OrganizationMembership})"); + table.HasCheckConstraint( + "CK_staff_identity_provisioning_anchors_ids", + "\"SourceId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND " + + "\"StaffMemberId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + table.HasCheckConstraint( + "CK_staff_identity_provisioning_anchors_resolution_event", + $"(\"SourceKind\" = " + + $"{(int)StaffIdentityProvisioningSourceKind.WorkspaceOnboarding} AND " + + "\"ResolutionEventId\" IS NOT NULL AND " + + "\"ResolutionEventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND " + + "\"ResolutionEventId\" <> \"SourceId\") OR " + + $"(\"SourceKind\" = " + + $"{(int)StaffIdentityProvisioningSourceKind.OrganizationMembership} AND " + + "\"ResolutionEventId\" IS NULL)"); + }); + builder.HasKey(anchor => new + { + anchor.ScopeId, + anchor.SourceKind, + anchor.SourceId + }); + builder.HasAlternateKey(anchor => new + { + anchor.ScopeId, + anchor.SourceKind, + anchor.SourceId, + anchor.StaffMemberId + }); + builder.Property(anchor => anchor.ScopeId) + .HasMaxLength(128) + .IsRequired(); + builder.Property(anchor => anchor.SourceKind) + .HasConversion() + .IsRequired(); + builder.HasIndex(anchor => anchor.ResolutionEventId) + .IsUnique() + .HasFilter("\"ResolutionEventId\" IS NOT NULL"); + builder.HasIndex(anchor => new + { + anchor.ScopeId, + anchor.SourceKind, + anchor.SourceId, + anchor.StaffMemberId, + anchor.ResolutionEventId + }).IsUnique() + .HasFilter("\"ResolutionEventId\" IS NOT NULL"); + builder.HasOne() + .WithMany() + .HasForeignKey(anchor => new + { + anchor.ScopeId, + anchor.StaffMemberId + }) + .HasPrincipalKey(member => new + { + member.ScopeId, + member.Id + }) + .OnDelete(DeleteBehavior.Restrict); + builder.HasIndex(anchor => new + { + anchor.ScopeId, + anchor.StaffMemberId, + anchor.SourceKind, + anchor.SourceId + }); + } +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Configurations/StaffIdentityProvisioningAnchorResolutionConfiguration.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Configurations/StaffIdentityProvisioningAnchorResolutionConfiguration.cs new file mode 100644 index 00000000..56994ba5 --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Configurations/StaffIdentityProvisioningAnchorResolutionConfiguration.cs @@ -0,0 +1,76 @@ +namespace BunkFy.Modules.Staff.Persistence.Configurations; + +using BunkFy.Modules.Staff.Application.Ports; +using BunkFy.Modules.Staff.Contracts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +internal sealed class StaffIdentityProvisioningAnchorResolutionConfiguration + : IEntityTypeConfiguration +{ + public void Configure( + EntityTypeBuilder builder) + { + builder.ToTable("identity_provisioning_anchor_resolutions", table => + { + table.HasTrigger( + "TR_staff_identity_provisioning_anchor_resolution_integrity"); + table.HasCheckConstraint( + "CK_staff_identity_provisioning_anchor_resolutions_source", + $"\"SourceKind\" = " + + $"{(int)StaffIdentityProvisioningSourceKind.WorkspaceOnboarding}"); + table.HasCheckConstraint( + "CK_staff_identity_provisioning_anchor_resolutions_ids", + "\"SourceId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND " + + "\"StaffMemberId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND " + + "\"ResolutionEventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND " + + "\"ResolutionEventId\" <> \"SourceId\""); + table.HasCheckConstraint( + "CK_staff_identity_provisioning_anchor_resolutions_evidence", + $"\"WorkspaceApplicationVersion\" >= 1 AND " + + $"\"Disposition\" BETWEEN " + + $"{(int)StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition.CompletedRedacted} AND " + + $"{(int)StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition.WithdrawnRedacted}"); + }); + builder.HasKey(resolution => new + { + resolution.ScopeId, + resolution.SourceKind, + resolution.SourceId + }); + builder.Property(resolution => resolution.ScopeId) + .HasMaxLength(128) + .IsRequired(); + builder.Property(resolution => resolution.SourceKind) + .HasConversion() + .IsRequired(); + builder.Property(resolution => resolution.Disposition) + .HasConversion() + .IsRequired(); + builder.HasOne() + .WithMany() + .HasForeignKey(resolution => new + { + resolution.ScopeId, + resolution.SourceKind, + resolution.SourceId, + resolution.StaffMemberId + }) + .HasPrincipalKey(anchor => new + { + anchor.ScopeId, + anchor.SourceKind, + anchor.SourceId, + anchor.StaffMemberId + }) + .OnDelete(DeleteBehavior.Restrict); + builder.HasIndex(resolution => new + { + resolution.ScopeId, + resolution.StaffMemberId, + resolution.SourceId + }); + builder.HasIndex(resolution => resolution.ResolutionEventId) + .IsUnique(); + } +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Configurations/StaffTenantDestroyOperationConfiguration.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Configurations/StaffTenantDestroyOperationConfiguration.cs index 5a7f471f..2ab32968 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Configurations/StaffTenantDestroyOperationConfiguration.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Configurations/StaffTenantDestroyOperationConfiguration.cs @@ -23,7 +23,7 @@ public void Configure( table.HasCheckConstraint( "CK_staff_tenant_destroy_operation_progress", $"\"Stage\" BETWEEN 1 AND " + - $"{(int)StaffTenantDestroyStage.MemberMutationOperations} AND " + + $"{(int)StaffTenantDestroyStage.IdentityProvisioningAnchorResolutions} AND " + "\"RemovedRecordCount\" >= 0 AND " + "\"CompletedBatchCount\" >= 0 AND " + "\"ProofVersion\" = 1 AND \"ConcurrencyVersion\" >= 1"); diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/DependencyInjection.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/DependencyInjection.cs index 94621537..b0ba4095 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/DependencyInjection.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/DependencyInjection.cs @@ -26,6 +26,12 @@ public static IHostApplicationBuilder AddStaffPersistence(this IHostApplicationB builder.Configuration, StaffMigrations.SqlServerAssembly, StaffMigrations.PostgreSqlAssembly, StaffMigrations.Schema, StaffMigrations.HistoryTable)); builder.Services.TryAddScoped(); + builder.Services.TryAddScoped< + IStaffIdentityProvisioningAnchorRepository, + StaffIdentityProvisioningAnchorRepository>(); + builder.Services.TryAddScoped< + IStaffIdentityProvisioningAnchorResolutionRepository, + StaffIdentityProvisioningAnchorResolutionRepository>(); builder.Services.TryAddScoped< IStaffMemberMutationOperationRepository, StaffMemberMutationOperationRepository>(); diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/IdentityProvisioningAnchorRecordId.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/IdentityProvisioningAnchorRecordId.cs new file mode 100644 index 00000000..15813c7a --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/IdentityProvisioningAnchorRecordId.cs @@ -0,0 +1,28 @@ +namespace BunkFy.Modules.Staff.Persistence.Repositories; + +using System.Globalization; +using BunkFy.Modules.DataRights.Contracts; +using BunkFy.Modules.Staff.Application.Ports; + +internal static class IdentityProvisioningAnchorRecordId +{ + public static Guid Create( + Guid staffMemberId, + StaffIdentityProvisioningSourceKind sourceKind, + Guid sourceId) => + DataRightsExportRecordIds.CreateDeterministicChild( + staffMemberId, + string.Create( + CultureInfo.InvariantCulture, + $"{(int)sourceKind}:{sourceId:N}")); + + public static Guid CreateResolution( + Guid staffMemberId, + StaffIdentityProvisioningSourceKind sourceKind, + Guid sourceId) => + DataRightsExportRecordIds.CreateDeterministicChild( + staffMemberId, + string.Create( + CultureInfo.InvariantCulture, + $"resolution:{(int)sourceKind}:{sourceId:N}")); +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffDataRightsExportContributor.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffDataRightsExportContributor.cs index 7937dc82..1577f9c7 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffDataRightsExportContributor.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffDataRightsExportContributor.cs @@ -15,12 +15,19 @@ internal sealed class StaffDataRightsExportContributor( public const int MaximumHoldRecords = StaffDataHold.MaximumRecordsPerStaffMember; public const int MaximumMemberMutationOperationRecords = 10_000; + public const int MaximumIdentityProvisioningAnchorRecords = 10_000; + public const int MaximumIdentityProvisioningAnchorResolutionRecords = + 10_000; public const string AssignmentRecordType = "staff-property-assignment"; public const string EmploymentGovernanceRecordType = "staff-employment-governance"; public const string DataHoldRecordType = "staff-data-hold"; public const string MemberMutationOperationRecordType = "staff-member-mutation-operation"; + public const string IdentityProvisioningAnchorRecordType = + "staff-identity-provisioning-anchor"; + public const string IdentityProvisioningAnchorResolutionRecordType = + "staff-identity-provisioning-anchor-resolution"; public string OwnerKey => StaffDataRightsDiscoveryContributor.Owner; @@ -208,11 +215,84 @@ await dbContext.MemberMutationOperations return DataRightsSubjectExportResult.ScopeUnavailable(); } + StaffIdentityProvisioningAnchorDataRightsExport[] + identityProvisioningAnchors = await dbContext + .IdentityProvisioningAnchors + .AsNoTracking() + .Where(anchor => + anchor.StaffMemberId == coordinate.RecordId) + .OrderBy(anchor => anchor.SourceKind) + .ThenBy(anchor => anchor.SourceId) + .Take(MaximumIdentityProvisioningAnchorRecords + 1) + .Select(anchor => + new StaffIdentityProvisioningAnchorDataRightsExport( + anchor.StaffMemberId, + anchor.SourceKind, + anchor.SourceId, + anchor.ResolutionEventId, + anchor.AnchoredAtUtc)) + .ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + if (identityProvisioningAnchors.Length > + MaximumIdentityProvisioningAnchorRecords) + { + return DataRightsSubjectExportResult.ScopeUnavailable(); + } + + StaffIdentityProvisioningAnchorResolutionDataRightsExport[] + identityProvisioningAnchorResolutions = await dbContext + .IdentityProvisioningAnchorResolutions + .AsNoTracking() + .Where(resolution => + resolution.StaffMemberId == coordinate.RecordId) + .OrderBy(resolution => resolution.SourceKind) + .ThenBy(resolution => resolution.SourceId) + .Take(MaximumIdentityProvisioningAnchorResolutionRecords + 1) + .Select(resolution => + new + StaffIdentityProvisioningAnchorResolutionDataRightsExport( + resolution.StaffMemberId, + resolution.SourceKind, + resolution.SourceId, + resolution.WorkspaceApplicationVersion, + resolution.Disposition, + resolution.ResolutionEventId, + resolution.ResolvedAtUtc)) + .ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + if (identityProvisioningAnchorResolutions.Length > + MaximumIdentityProvisioningAnchorResolutionRecords) + { + return DataRightsSubjectExportResult.ScopeUnavailable(); + } + await sink.WriteAsync( StaffDataRightsExportSchema.CreateProfileRecord(snapshot.Profile), cancellationToken).ConfigureAwait(false); int recordCount = 1; + foreach (StaffIdentityProvisioningAnchorDataRightsExport anchor in + identityProvisioningAnchors) + { + await sink.WriteAsync( + StaffDataRightsExportSchema + .CreateIdentityProvisioningAnchorRecord(anchor), + cancellationToken).ConfigureAwait(false); + recordCount = checked(recordCount + 1); + } + + foreach ( + StaffIdentityProvisioningAnchorResolutionDataRightsExport + resolution in identityProvisioningAnchorResolutions) + { + await sink.WriteAsync( + StaffDataRightsExportSchema + .CreateIdentityProvisioningAnchorResolutionRecord( + resolution), + cancellationToken).ConfigureAwait(false); + recordCount = checked(recordCount + 1); + } + foreach (StaffMemberMutationOperationDataRightsExport operation in memberMutationOperations) { diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffDataRightsExportModels.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffDataRightsExportModels.cs index 8755359a..9e8d1b79 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffDataRightsExportModels.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffDataRightsExportModels.cs @@ -78,3 +78,19 @@ internal sealed record StaffMemberMutationOperationDataRightsExport( StaffStatus ResultStatus, long ResultVersion, DateTimeOffset CompletedAtUtc); + +internal sealed record StaffIdentityProvisioningAnchorDataRightsExport( + Guid StaffMemberId, + StaffIdentityProvisioningSourceKind SourceKind, + Guid SourceId, + Guid? ResolutionEventId, + DateTimeOffset AnchoredAtUtc); + +internal sealed record StaffIdentityProvisioningAnchorResolutionDataRightsExport( + Guid StaffMemberId, + StaffIdentityProvisioningSourceKind SourceKind, + Guid SourceId, + long WorkspaceApplicationVersion, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition Disposition, + Guid ResolutionEventId, + DateTimeOffset ResolvedAtUtc); diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffDataRightsExportSchema.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffDataRightsExportSchema.cs index 7e85e810..b0fdb80a 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffDataRightsExportSchema.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffDataRightsExportSchema.cs @@ -10,7 +10,7 @@ namespace BunkFy.Modules.Staff.Persistence.Repositories; internal static class StaffDataRightsExportSchema { public const string ExportSchemaId = "staff.subject-export"; - public const int ExportSchemaVersion = 2; + public const int ExportSchemaVersion = 4; private const string CatalogResourceName = "BunkFy.Modules.Staff.Persistence.DataGovernance.personal-data-catalog.v1.json"; @@ -142,6 +142,52 @@ public static DataRightsExportRecord CreateMemberMutationOperationRecord( (nameof(operation.CompletedAtUtc), operation.CompletedAtUtc) ]); + public static DataRightsExportRecord + CreateIdentityProvisioningAnchorRecord( + StaffIdentityProvisioningAnchorDataRightsExport anchor) => + CreateRecord( + StaffDataRightsExportContributor + .IdentityProvisioningAnchorRecordType, + IdentityProvisioningAnchorRecordId.Create( + anchor.StaffMemberId, + anchor.SourceKind, + anchor.SourceId), + recordVersion: 1, + typeof(StaffIdentityProvisioningAnchorDataRightsExport), + [ + (nameof(anchor.StaffMemberId), anchor.StaffMemberId), + (nameof(anchor.SourceKind), anchor.SourceKind), + (nameof(anchor.SourceId), anchor.SourceId), + (nameof(anchor.ResolutionEventId), + anchor.ResolutionEventId), + (nameof(anchor.AnchoredAtUtc), anchor.AnchoredAtUtc) + ]); + + public static DataRightsExportRecord + CreateIdentityProvisioningAnchorResolutionRecord( + StaffIdentityProvisioningAnchorResolutionDataRightsExport + resolution) => + CreateRecord( + StaffDataRightsExportContributor + .IdentityProvisioningAnchorResolutionRecordType, + IdentityProvisioningAnchorRecordId.CreateResolution( + resolution.StaffMemberId, + resolution.SourceKind, + resolution.SourceId), + resolution.WorkspaceApplicationVersion, + typeof(StaffIdentityProvisioningAnchorResolutionDataRightsExport), + [ + (nameof(resolution.StaffMemberId), resolution.StaffMemberId), + (nameof(resolution.SourceKind), resolution.SourceKind), + (nameof(resolution.SourceId), resolution.SourceId), + (nameof(resolution.WorkspaceApplicationVersion), + resolution.WorkspaceApplicationVersion), + (nameof(resolution.Disposition), resolution.Disposition), + (nameof(resolution.ResolutionEventId), + resolution.ResolutionEventId), + (nameof(resolution.ResolvedAtUtc), resolution.ResolvedAtUtc) + ]); + private static DataRightsExportRecord CreateRecord( string recordType, Guid recordId, @@ -215,7 +261,9 @@ private static SchemaState Load() typeof(StaffAssignmentDataRightsExport), typeof(StaffEmploymentGovernanceDataRightsExport), typeof(StaffDataHoldDataRightsExport), - typeof(StaffMemberMutationOperationDataRightsExport) + typeof(StaffMemberMutationOperationDataRightsExport), + typeof(StaffIdentityProvisioningAnchorDataRightsExport), + typeof(StaffIdentityProvisioningAnchorResolutionDataRightsExport) ]; HashSet expectedMembers = sourceTypes .SelectMany(type => type.GetProperties(BindingFlags.Instance | BindingFlags.Public) diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffIdentityProvisioningAnchorRepository.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffIdentityProvisioningAnchorRepository.cs new file mode 100644 index 00000000..ecefcb6b --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffIdentityProvisioningAnchorRepository.cs @@ -0,0 +1,77 @@ +namespace BunkFy.Modules.Staff.Persistence.Repositories; + +using BunkFy.Modules.Staff.Application.Ports; +using Microsoft.EntityFrameworkCore; + +internal sealed class StaffIdentityProvisioningAnchorRepository( + StaffDbContext dbContext) : IStaffIdentityProvisioningAnchorRepository +{ + public async Task> + ListAsync( + IReadOnlyList sources, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sources); + Guid[] workspaceSourceIds = sources + .Where(source => source.SourceKind == + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding) + .Select(source => source.SourceId) + .ToArray(); + Guid[] membershipSourceIds = sources + .Where(source => source.SourceKind == + StaffIdentityProvisioningSourceKind.OrganizationMembership) + .Select(source => source.SourceId) + .ToArray(); + StaffIdentityProvisioningAnchor[] loaded = await dbContext + .IdentityProvisioningAnchors + .AsNoTracking() + .Where(anchor => + (anchor.SourceKind == + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding && + workspaceSourceIds.Contains(anchor.SourceId)) || + (anchor.SourceKind == + StaffIdentityProvisioningSourceKind + .OrganizationMembership && + membershipSourceIds.Contains(anchor.SourceId))) + .OrderBy(anchor => anchor.SourceKind) + .ThenBy(anchor => anchor.SourceId) + .ToArrayAsync(cancellationToken).ConfigureAwait(false); + return loaded.Select(anchor => anchor.ToRecord()).ToArray(); + } + + public async Task GetAsync( + StaffIdentityProvisioningSourceKind sourceKind, + Guid sourceId, + CancellationToken cancellationToken) + { + StaffIdentityProvisioningAnchor? anchor = await dbContext + .IdentityProvisioningAnchors + .AsNoTracking() + .SingleOrDefaultAsync( + item => item.SourceKind == sourceKind && + item.SourceId == sourceId, + cancellationToken).ConfigureAwait(false); + return anchor?.ToRecord(); + } + + public Task HasWorkspaceOnboardingAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => dbContext + .IdentityProvisioningAnchors + .AsNoTracking() + .AnyAsync( + anchor => anchor.SourceKind == + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding && + anchor.StaffMemberId == staffMemberId, + cancellationToken); + + public Task AddAsync( + StaffIdentityProvisioningAnchorRecord anchor, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(anchor); + dbContext.IdentityProvisioningAnchors.Add( + new StaffIdentityProvisioningAnchor(anchor)); + return Task.CompletedTask; + } +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffIdentityProvisioningAnchorResolutionRepository.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffIdentityProvisioningAnchorResolutionRepository.cs new file mode 100644 index 00000000..7a9bba37 --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffIdentityProvisioningAnchorResolutionRepository.cs @@ -0,0 +1,74 @@ +namespace BunkFy.Modules.Staff.Persistence.Repositories; + +using BunkFy.Modules.Staff.Application.Ports; +using Microsoft.EntityFrameworkCore; + +internal sealed class StaffIdentityProvisioningAnchorResolutionRepository( + StaffDbContext dbContext) + : IStaffIdentityProvisioningAnchorResolutionRepository +{ + public async Task> ListAsync( + IReadOnlyList sourceIds, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sourceIds); + StaffIdentityProvisioningAnchorResolution[] resolutions = + await dbContext.IdentityProvisioningAnchorResolutions + .AsNoTracking() + .Where(resolution => resolution.SourceKind == + StaffIdentityProvisioningSourceKind + .WorkspaceOnboarding && + sourceIds.Contains(resolution.SourceId)) + .OrderBy(resolution => resolution.SourceId) + .ToArrayAsync(cancellationToken).ConfigureAwait(false); + return resolutions.Select(resolution => resolution.ToRecord()) + .ToArray(); + } + + public async Task + GetAsync( + Guid sourceId, + CancellationToken cancellationToken) + { + StaffIdentityProvisioningAnchorResolution? resolution = + await dbContext.IdentityProvisioningAnchorResolutions + .AsNoTracking() + .SingleOrDefaultAsync( + item => item.SourceKind == + StaffIdentityProvisioningSourceKind + .WorkspaceOnboarding && + item.SourceId == sourceId, + cancellationToken).ConfigureAwait(false); + return resolution?.ToRecord(); + } + + public Task HasUnresolvedWorkspaceOnboardingAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + dbContext.IdentityProvisioningAnchors + .AsNoTracking() + .Where(anchor => anchor.SourceKind == + StaffIdentityProvisioningSourceKind + .WorkspaceOnboarding && + anchor.StaffMemberId == staffMemberId) + .AnyAsync( + anchor => !dbContext.IdentityProvisioningAnchorResolutions + .Any(resolution => + resolution.SourceKind == anchor.SourceKind && + resolution.SourceId == anchor.SourceId && + resolution.StaffMemberId == anchor.StaffMemberId && + resolution.ResolutionEventId == + anchor.ResolutionEventId), + cancellationToken); + + public Task AddAsync( + StaffIdentityProvisioningAnchorResolutionRecord resolution, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resolution); + dbContext.IdentityProvisioningAnchorResolutions.Add( + new StaffIdentityProvisioningAnchorResolution(resolution)); + return Task.CompletedTask; + } +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffMemberRepository.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffMemberRepository.cs index 4a8f642d..3ea675a2 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffMemberRepository.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffMemberRepository.cs @@ -6,12 +6,30 @@ namespace BunkFy.Modules.Staff.Persistence.Repositories; using BunkFy.Modules.Staff.Contracts; using BunkFy.Modules.Staff.Domain.Aggregates; using BunkFy.Modules.Staff.Domain.DataRights; +using BunkFy.Modules.Staff.Domain.Entities; using BunkFy.Modules.Staff.Persistence.Models; using Gma.Framework.Results; internal sealed class StaffMemberRepository(StaffDbContext dbContext) : IStaffMemberRepository { + public async Task> + ListSafetyEvidenceAsync( + IReadOnlyList staffMemberIds, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(staffMemberIds); + return await dbContext.StaffMembers + .AsNoTracking() + .Where(member => staffMemberIds.Contains(member.Id)) + .OrderBy(member => member.Id) + .Select(member => new StaffMemberSafetyEvidence( + member.Id, + member.AuthSubjectId, + member.Status)) + .ToArrayAsync(cancellationToken).ConfigureAwait(false); + } + public Task AddAsync( StaffMember member, CancellationToken cancellationToken) @@ -44,6 +62,14 @@ public Task AddAsync( .Include(member => member.Assignments) .FirstOrDefaultAsync(member => member.Id == staffMemberId, cancellationToken); + public Task ReloadOperationalAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + this.ReloadAfterOperationLockAsync( + staffMemberId, + operational: true, + cancellationToken); + public Task GetForDataRightsAsync( Guid staffMemberId, CancellationToken cancellationToken) => @@ -62,6 +88,71 @@ public Task AddAsync( member => member.Id == staffMemberId, cancellationToken); + public async Task ReloadForSafetyTransitionAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + await this.ReloadAfterOperationLockAsync( + staffMemberId, + operational: false, + cancellationToken).ConfigureAwait(false); + + private async Task ReloadAfterOperationLockAsync( + Guid staffMemberId, + bool operational, + CancellationToken cancellationToken) + { + StaffMember? tracked = dbContext.StaffMembers.Local + .SingleOrDefault(member => member.Id == staffMemberId); + if (tracked is not null) + { + Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry< + StaffMember> memberEntry = dbContext.Entry(tracked); + if (memberEntry.State != EntityState.Unchanged) + { + throw new InvalidOperationException( + "A changed Staff member cannot be reloaded after its operation lock."); + } + + foreach (StaffPropertyAssignment assignment in + tracked.Assignments.ToArray()) + { + Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry< + StaffPropertyAssignment> assignmentEntry = + dbContext.Entry(assignment); + if (assignmentEntry.State != EntityState.Unchanged) + { + throw new InvalidOperationException( + "Changed Staff assignments cannot be reloaded after the member operation lock."); + } + + assignmentEntry.State = EntityState.Detached; + } + + memberEntry.State = EntityState.Detached; + } + + IQueryable source = operational + ? this.OperationalMembers() + : dbContext.StaffMembers; + return await source + .Include(member => member.Assignments) + .SingleOrDefaultAsync( + member => member.Id == staffMemberId, + cancellationToken).ConfigureAwait(false); + } + + public Task GetForSafetyTransitionByAuthSubjectAsync( + string authSubjectId, + CancellationToken cancellationToken) + { + string normalized = authSubjectId.Trim(); + return dbContext.StaffMembers + .Include(member => member.Assignments) + .FirstOrDefaultAsync( + member => member.AuthSubjectId == normalized, + cancellationToken); + } + public Task GetByAuthSubjectAsync(string authSubjectId, CancellationToken cancellationToken) { string normalized = authSubjectId.Trim(); diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffOnboardingProvisioningOperationRepository.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffOnboardingProvisioningOperationRepository.cs index 2654eecd..9a3b222b 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffOnboardingProvisioningOperationRepository.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffOnboardingProvisioningOperationRepository.cs @@ -8,6 +8,23 @@ internal sealed class StaffOnboardingProvisioningOperationRepository( StaffDbContext dbContext) : IStaffOnboardingProvisioningOperationRepository { + public async Task> + ListAsync( + IReadOnlyList operationIds, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(operationIds); + StaffMemberMutationOperation[] loaded = await dbContext + .MemberMutationOperations + .AsNoTracking() + .Where(operation => operationIds.Contains(operation.Id) && + operation.Kind == + StaffMemberMutationKind.OnboardingProvision) + .OrderBy(operation => operation.Id) + .ToArrayAsync(cancellationToken).ConfigureAwait(false); + return loaded.Select(operation => operation.ToRecord()).ToArray(); + } + public async Task GetAsync( Guid operationId, CancellationToken cancellationToken) diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationContributor.Destroy.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationContributor.Destroy.cs index 5bb88b37..666062b6 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationContributor.Destroy.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationContributor.Destroy.cs @@ -229,6 +229,7 @@ await dbContext.SaveTenantDestructionChangesAsync( while (!operation.IsComplete) { + StaffTenantDestroyStage attemptedStage = operation.Stage; bool removed = await this.RemoveCurrentStageAsync( operation, tenantId, @@ -236,13 +237,24 @@ await dbContext.SaveTenantDestructionChangesAsync( .ConfigureAwait(false); if (!removed) { + // Persist every empty-stage transition before the next + // stage can delete immutable identity evidence. The + // PostgreSQL trigger validates the durable destroy stage, + // so it must not depend on EF command ordering between an + // unrelated operation update and a sensitive delete. + await dbContext.SaveTenantDestructionChangesAsync( + tenantId, + request.IdempotencyKey, + cancellationToken) + .ConfigureAwait(false); continue; } await dbContext.SaveTenantDestructionChangesAsync( tenantId, request.IdempotencyKey, - cancellationToken) + cancellationToken, + attemptedStage) .ConfigureAwait(false); return await FinishAsync( transaction, diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationContributor.DestroyBatches.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationContributor.DestroyBatches.cs index fd205383..f8a9c547 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationContributor.DestroyBatches.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationContributor.DestroyBatches.cs @@ -118,6 +118,26 @@ private Task RemoveCurrentStageAsync( dbContext.OperationLocks, resourceLock => resourceLock.Id, cancellationToken), + StaffTenantDestroyStage.IdentityProvisioningAnchorResolutions => + this.RemoveBatchAsync( + operation, + dbContext.IdentityProvisioningAnchorResolutions + .OrderBy(resolution => resolution.SourceKind) + .ThenBy(resolution => resolution.SourceId), + resolution => + $"{(int)resolution.SourceKind}|" + + $"{resolution.SourceId:N}|" + + $"{resolution.StaffMemberId:N}", + cancellationToken), + StaffTenantDestroyStage.IdentityProvisioningAnchors => + this.RemoveBatchAsync( + operation, + dbContext.IdentityProvisioningAnchors + .OrderBy(anchor => anchor.SourceKind) + .ThenBy(anchor => anchor.SourceId), + anchor => + $"{(int)anchor.SourceKind}|{anchor.SourceId:N}", + cancellationToken), StaffTenantDestroyStage.StaffMembers => this.RemoveGuidBatchAsync( operation, @@ -244,6 +264,10 @@ await dbContext.RetentionExecutions.AnyAsync(cancellationToken) .ConfigureAwait(false) || await dbContext.OperationLocks.AnyAsync(cancellationToken) .ConfigureAwait(false) || + await dbContext.IdentityProvisioningAnchorResolutions + .AnyAsync(cancellationToken).ConfigureAwait(false) || + await dbContext.IdentityProvisioningAnchors + .AnyAsync(cancellationToken).ConfigureAwait(false) || await dbContext.StaffMembers.AnyAsync(cancellationToken) .ConfigureAwait(false) || await dbContext.ProcessingRestrictionProjections diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationContributor.IdentityProvisioningAnchors.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationContributor.IdentityProvisioningAnchors.cs new file mode 100644 index 00000000..55f312a5 --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationContributor.IdentityProvisioningAnchors.cs @@ -0,0 +1,94 @@ +namespace BunkFy.Modules.Staff.Persistence.Repositories; + +using BunkFy.Modules.DataRights.Contracts; +using BunkFy.Modules.Staff.Contracts; +using Microsoft.EntityFrameworkCore; + +internal sealed partial class StaffTenantTerminationContributor +{ + private async Task ExportIdentityProvisioningAnchorsAsync( + string tenantId, + IDataRightsExportSink sink, + long count, + CancellationToken cancellationToken) + { + await foreach (StaffIdentityProvisioningAnchor anchor in + dbContext.IdentityProvisioningAnchors + .AsNoTracking() + .Where(item => item.ScopeId == tenantId) + .OrderBy(item => item.StaffMemberId) + .ThenBy(item => item.SourceKind) + .ThenBy(item => item.SourceId) + .AsAsyncEnumerable() + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + StaffIdentityProvisioningAnchorTenantExport record = new( + anchor.ScopeId, + anchor.StaffMemberId, + new StaffIdentityProvisioningAnchorStateTenantExport( + anchor.SourceKind, + anchor.SourceId, + anchor.ResolutionEventId, + anchor.AnchoredAtUtc)); + await sink.WriteAsync( + StaffTenantTerminationExportSchema.CreateRecord( + StaffTenantTerminationMetadata + .IdentityProvisioningAnchorRecordType, + IdentityProvisioningAnchorRecordId.Create( + anchor.StaffMemberId, + anchor.SourceKind, + anchor.SourceId), + recordVersion: 1, + record), + cancellationToken).ConfigureAwait(false); + count = checked(count + 1); + } + + return count; + } + + private async Task ExportIdentityProvisioningAnchorResolutionsAsync( + string tenantId, + IDataRightsExportSink sink, + long count, + CancellationToken cancellationToken) + { + await foreach (StaffIdentityProvisioningAnchorResolution resolution in + dbContext.IdentityProvisioningAnchorResolutions + .AsNoTracking() + .Where(item => item.ScopeId == tenantId) + .OrderBy(item => item.StaffMemberId) + .ThenBy(item => item.SourceKind) + .ThenBy(item => item.SourceId) + .AsAsyncEnumerable() + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + StaffIdentityProvisioningAnchorResolutionTenantExport record = new( + resolution.ScopeId, + resolution.StaffMemberId, + new StaffIdentityProvisioningAnchorResolutionStateTenantExport( + resolution.SourceKind, + resolution.SourceId, + resolution.WorkspaceApplicationVersion, + resolution.Disposition, + resolution.ResolutionEventId, + resolution.ResolvedAtUtc)); + await sink.WriteAsync( + StaffTenantTerminationExportSchema.CreateRecord( + StaffTenantTerminationMetadata + .IdentityProvisioningAnchorResolutionRecordType, + IdentityProvisioningAnchorRecordId.CreateResolution( + resolution.StaffMemberId, + resolution.SourceKind, + resolution.SourceId), + resolution.WorkspaceApplicationVersion, + record), + cancellationToken).ConfigureAwait(false); + count = checked(count + 1); + } + + return count; + } +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationContributor.Records.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationContributor.Records.cs index ba803c32..d7707953 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationContributor.Records.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationContributor.Records.cs @@ -19,6 +19,16 @@ private async Task ExportRecordsAsync( sink, count, cancellationToken).ConfigureAwait(false); + count = await this.ExportIdentityProvisioningAnchorsAsync( + tenantId, + sink, + count, + cancellationToken).ConfigureAwait(false); + count = await this.ExportIdentityProvisioningAnchorResolutionsAsync( + tenantId, + sink, + count, + cancellationToken).ConfigureAwait(false); count = await this.ExportPropertyAssignmentsAsync( tenantId, sink, diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationExportModels.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationExportModels.cs index 7c79068c..08a1e19e 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationExportModels.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationExportModels.cs @@ -5,6 +5,7 @@ namespace BunkFy.Modules.Staff.Persistence.Repositories; using BunkFy.Modules.Staff.Domain.Models; using BunkFy.Modules.Staff.Domain.Retention; using StaffContractStatus = BunkFy.Modules.Staff.Contracts.StaffStatus; +using StaffAnchorResolutionDisposition = BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition; [AttributeUsage(AttributeTargets.Property)] internal sealed class StaffTenantExportFieldAttribute(string fieldId) @@ -63,6 +64,39 @@ internal sealed record StaffMemberMutationOperationStateTenantExport( long ResultVersion, DateTimeOffset CompletedAtUtc); +internal sealed record StaffIdentityProvisioningAnchorTenantExport( + [property: StaffTenantExportField("staff.scope-id")] + string ScopeId, + [property: StaffTenantExportField("staff.staff-member-id")] + Guid StaffMemberId, + [property: StaffTenantExportField("staff.identity-provisioning-anchor")] + StaffIdentityProvisioningAnchorStateTenantExport + IdentityProvisioningAnchor); + +internal sealed record StaffIdentityProvisioningAnchorStateTenantExport( + StaffIdentityProvisioningSourceKind SourceKind, + Guid SourceId, + Guid? ResolutionEventId, + DateTimeOffset AnchoredAtUtc); + +internal sealed record StaffIdentityProvisioningAnchorResolutionTenantExport( + [property: StaffTenantExportField("staff.scope-id")] + string ScopeId, + [property: StaffTenantExportField("staff.staff-member-id")] + Guid StaffMemberId, + [property: StaffTenantExportField( + "staff.identity-provisioning-anchor-resolution")] + StaffIdentityProvisioningAnchorResolutionStateTenantExport + IdentityProvisioningAnchorResolution); + +internal sealed record StaffIdentityProvisioningAnchorResolutionStateTenantExport( + StaffIdentityProvisioningSourceKind SourceKind, + Guid SourceId, + long WorkspaceApplicationVersion, + StaffAnchorResolutionDisposition Disposition, + Guid ResolutionEventId, + DateTimeOffset ResolvedAtUtc); + internal sealed record StaffPropertyAssignmentTenantExport( [property: StaffTenantExportField("staff.scope-id")] string ScopeId, diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationExportSchema.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationExportSchema.cs index 20d3280e..f2620ea8 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationExportSchema.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/Repositories/StaffTenantTerminationExportSchema.cs @@ -20,6 +20,8 @@ internal static class StaffTenantTerminationExportSchema [ typeof(StaffMemberTenantExport), typeof(StaffMemberMutationOperationTenantExport), + typeof(StaffIdentityProvisioningAnchorTenantExport), + typeof(StaffIdentityProvisioningAnchorResolutionTenantExport), typeof(StaffPropertyAssignmentTenantExport), typeof(StaffDataRightsCorrectionReceiptTenantExport), typeof(StaffProcessingRestrictionTenantExport), @@ -47,6 +49,16 @@ internal static class StaffTenantTerminationExportSchema .MemberMutationOperation), "staff.member-mutation-operation", "include-in-authorized-staff-or-tenant-export"), + Binding( + nameof(StaffIdentityProvisioningAnchorTenantExport + .IdentityProvisioningAnchor), + "staff.identity-provisioning-anchor", + "include-in-authorized-staff-or-tenant-export"), + Binding( + nameof(StaffIdentityProvisioningAnchorResolutionTenantExport + .IdentityProvisioningAnchorResolution), + "staff.identity-provisioning-anchor-resolution", + "include-in-authorized-staff-or-tenant-export"), Binding( nameof(StaffPropertyAssignmentTenantExport.AssignmentState), "staff.assignment-record", diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/StaffDbContext.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/StaffDbContext.cs index 5ce052f7..a157d5dd 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/StaffDbContext.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/StaffDbContext.cs @@ -24,6 +24,12 @@ public sealed class StaffDbContext( private readonly IScopeContext scopeContext = scopeContext; public DbSet StaffMembers => this.Set(); + internal DbSet + IdentityProvisioningAnchors => + this.Set(); + internal DbSet + IdentityProvisioningAnchorResolutions => + this.Set(); internal DbSet MemberMutationOperations => this.Set(); public DbSet DataRightsCorrectionReceipts => @@ -146,6 +152,16 @@ entry.State is bool memberMutationOperationMutationRequested = this.ChangeTracker .Entries() .Any(entry => entry.State == EntityState.Modified); + bool identityProvisioningAnchorMutationRequested = this.ChangeTracker + .Entries() + .Any(entry => + entry.State is EntityState.Modified or EntityState.Deleted); + bool identityProvisioningAnchorResolutionMutationRequested = + this.ChangeTracker + .Entries() + .Any(entry => + entry.State is + EntityState.Modified or EntityState.Deleted); if (correctionMutationRequested || restrictionMutationRequested || governanceMutationRequested || @@ -155,7 +171,9 @@ entry.State is retentionReceiptMutationRequested || tombstoneDeletionRequested || tenantDestroyReceiptMutationRequested || - memberMutationOperationMutationRequested) + memberMutationOperationMutationRequested || + identityProvisioningAnchorMutationRequested || + identityProvisioningAnchorResolutionMutationRequested) { throw new InvalidOperationException( "Staff immutable receipts are append-only."); @@ -420,7 +438,8 @@ await this.EnsureOperationalAdmissionAsync(cancellationToken) internal async Task SaveTenantDestructionChangesAsync( string tenantId, Guid operationId, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + StaffTenantDestroyStage? attemptedStage = null) { if (!TenantIds.TryNormalize(tenantId, out string? canonicalTenantId) || operationId == Guid.Empty || @@ -450,12 +469,49 @@ StaffTenantLifecycleStatus.Closing or "Staff tenant destruction state is invalid."); } + StaffTenantDestroyOperation? operation = + this.TenantDestroyOperations.Local.SingleOrDefault(item => + item.OperationId == operationId) ?? + await this.TenantDestroyOperations.SingleOrDefaultAsync( + item => item.OperationId == operationId, + cancellationToken).ConfigureAwait(false); + if (operation is null) + { + throw new InvalidOperationException( + "Staff tenant destruction operation is invalid."); + } + + StaffTenantDestroyStage authorizedStage = + attemptedStage ?? operation.Stage; + bool anchorDeletionRequested = this.ChangeTracker + .Entries() + .Any(entry => entry.State == EntityState.Deleted); + bool resolutionDeletionRequested = this.ChangeTracker + .Entries() + .Any(entry => entry.State == EntityState.Deleted); + if (anchorDeletionRequested || resolutionDeletionRequested) + { + if ((anchorDeletionRequested && authorizedStage != + StaffTenantDestroyStage.IdentityProvisioningAnchors) || + (resolutionDeletionRequested && authorizedStage != + StaffTenantDestroyStage + .IdentityProvisioningAnchorResolutions)) + { + throw new InvalidOperationException( + "Staff identity-anchor destruction stage is invalid."); + } + } + if (this.Database.IsNpgsql()) { await this.Database.ExecuteSqlInterpolatedAsync( $"SELECT set_config('bunkfy.staff_tenant_destroy_operation_id', {operationId.ToString("D")}, true)", cancellationToken) .ConfigureAwait(false); + await this.Database.ExecuteSqlInterpolatedAsync( + $"SELECT set_config('bunkfy.staff_tenant_destroy_stage', {((int)authorizedStage).ToString(System.Globalization.CultureInfo.InvariantCulture)}, true)", + cancellationToken) + .ConfigureAwait(false); } return await base.SaveChangesAsync( diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/StaffIdentityProvisioningAnchor.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/StaffIdentityProvisioningAnchor.cs new file mode 100644 index 00000000..03fc787b --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/StaffIdentityProvisioningAnchor.cs @@ -0,0 +1,35 @@ +namespace BunkFy.Modules.Staff.Persistence; + +using BunkFy.Modules.Staff.Application.Ports; +using Gma.Framework.Domain; + +internal sealed class StaffIdentityProvisioningAnchor : IScopedEntity +{ + private StaffIdentityProvisioningAnchor() { } + + internal StaffIdentityProvisioningAnchor( + StaffIdentityProvisioningAnchorRecord record) + { + this.ScopeId = record.ScopeId; + this.SourceKind = record.SourceKind; + this.SourceId = record.SourceId; + this.StaffMemberId = record.StaffMemberId; + this.AnchoredAtUtc = record.AnchoredAtUtc; + this.ResolutionEventId = record.ResolutionEventId; + } + + public string ScopeId { get; private set; } = string.Empty; + public StaffIdentityProvisioningSourceKind SourceKind { get; private set; } + public Guid SourceId { get; private set; } + public Guid StaffMemberId { get; private set; } + public DateTimeOffset AnchoredAtUtc { get; private set; } + public Guid? ResolutionEventId { get; private set; } + + internal StaffIdentityProvisioningAnchorRecord ToRecord() => new( + this.ScopeId, + this.SourceKind, + this.SourceId, + this.StaffMemberId, + this.AnchoredAtUtc, + this.ResolutionEventId); +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/StaffIdentityProvisioningAnchorResolution.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/StaffIdentityProvisioningAnchorResolution.cs new file mode 100644 index 00000000..1e8333d6 --- /dev/null +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/StaffIdentityProvisioningAnchorResolution.cs @@ -0,0 +1,49 @@ +namespace BunkFy.Modules.Staff.Persistence; + +using BunkFy.Modules.Staff.Application.Ports; +using BunkFy.Modules.Staff.Contracts; +using Gma.Framework.Domain; + +internal sealed class StaffIdentityProvisioningAnchorResolution + : IScopedEntity +{ + private StaffIdentityProvisioningAnchorResolution() { } + + internal StaffIdentityProvisioningAnchorResolution( + StaffIdentityProvisioningAnchorResolutionRecord record) + { + this.ScopeId = record.ScopeId; + this.SourceKind = record.SourceKind; + this.SourceId = record.SourceId; + this.StaffMemberId = record.StaffMemberId; + this.WorkspaceApplicationVersion = + record.WorkspaceApplicationVersion; + this.Disposition = record.Disposition; + this.ResolutionEventId = record.ResolutionEventId; + this.ResolvedAtUtc = record.ResolvedAtUtc; + } + + public string ScopeId { get; private set; } = string.Empty; + public StaffIdentityProvisioningSourceKind SourceKind { get; private set; } + public Guid SourceId { get; private set; } + public Guid StaffMemberId { get; private set; } + public long WorkspaceApplicationVersion { get; private set; } + public StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition Disposition + { + get; + private set; + } + public Guid ResolutionEventId { get; private set; } + public DateTimeOffset ResolvedAtUtc { get; private set; } + + internal StaffIdentityProvisioningAnchorResolutionRecord ToRecord() => + new( + this.ScopeId, + this.SourceKind, + this.SourceId, + this.StaffMemberId, + this.WorkspaceApplicationVersion, + this.Disposition, + this.ResolutionEventId, + this.ResolvedAtUtc); +} diff --git a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/TenantTermination/StaffTenantDestroyOperation.cs b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/TenantTermination/StaffTenantDestroyOperation.cs index f53f4b10..912172dc 100644 --- a/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/TenantTermination/StaffTenantDestroyOperation.cs +++ b/src/Modules/Staff/BunkFy.Modules.Staff.Persistence/TenantTermination/StaffTenantDestroyOperation.cs @@ -140,7 +140,7 @@ private static StaffTenantDestroyStage Next( StaffTenantDestroyStage stage) { if (stage is < StaffTenantDestroyStage.OutboxMessages or - > StaffTenantDestroyStage.MemberMutationOperations or + > StaffTenantDestroyStage.IdentityProvisioningAnchorResolutions or StaffTenantDestroyStage.Completed) { throw new InvalidOperationException( @@ -149,6 +149,12 @@ private static StaffTenantDestroyStage Next( return stage switch { + StaffTenantDestroyStage.OperationLocks => + StaffTenantDestroyStage.IdentityProvisioningAnchorResolutions, + StaffTenantDestroyStage.IdentityProvisioningAnchorResolutions => + StaffTenantDestroyStage.IdentityProvisioningAnchors, + StaffTenantDestroyStage.IdentityProvisioningAnchors => + StaffTenantDestroyStage.StaffMembers, StaffTenantDestroyStage.RetentionSweepCheckpoints => StaffTenantDestroyStage.MemberMutationOperations, StaffTenantDestroyStage.MemberMutationOperations => @@ -183,5 +189,7 @@ internal enum StaffTenantDestroyStage ProjectionRebuildCheckpoints = 20, RetentionSweepCheckpoints = 21, Completed = 22, - MemberMutationOperations = 23 + MemberMutationOperations = 23, + IdentityProvisioningAnchors = 24, + IdentityProvisioningAnchorResolutions = 25 } diff --git a/src/Modules/Staff/docs/personal-data-catalog.v1.json b/src/Modules/Staff/docs/personal-data-catalog.v1.json index 53e163cf..2e14e1c2 100644 --- a/src/Modules/Staff/docs/personal-data-catalog.v1.json +++ b/src/Modules/Staff/docs/personal-data-catalog.v1.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "catalogId": "staff.personal-data", - "catalogVersion": 19, + "catalogVersion": 20, "module": "staff", "approvalState": "engineering-default", "accessPolicies": [ @@ -142,6 +142,21 @@ "system:staff.retention" ] }, + { + "id": "staff-identity-provisioning-anchors", + "scope": "tenant-staff-identity-provisioning", + "readers": [ + "permission:staff.sensitive-profile.read", + "subject:self", + "system:staff.data-rights-export", + "system:staff.tenant-export", + "system:workspace-staff-onboarding" + ], + "writers": [ + "system:organizations.owner-bootstrap", + "system:workspace-staff-onboarding" + ] + }, { "id": "staff-retention-execution", "scope": "tenant-internal-retention-worker", @@ -293,6 +308,13 @@ "endsAt": "approved-staff-anonymisation-or-employment-retention-or-tenant-termination", "legalHoldBehavior": "delete-with-owner-profile-disposition" }, + { + "id": "staff-identity-provisioning-anchor", + "approvalState": "engineering-default", + "startsAt": "staff-identity-provisioning-anchored", + "endsAt": "tenant-termination", + "legalHoldBehavior": "retain-durable-provisioning-coordinate" + }, { "id": "staff-retention-anonymisation-proof", "approvalState": "engineering-default", @@ -400,6 +422,13 @@ "restriction": "retain-for-exact-idempotent-replay", "erasure": "delete-with-approved-staff-anonymisation" }, + { + "id": "staff-identity-provisioning-anchor-control", + "export": "include-in-authorized-staff-or-tenant-export", + "correction": "immutable-provisioning-coordinate", + "restriction": "retain-for-identity-provisioning-reconciliation", + "erasure": "retain-pseudonymous-linkage-until-tenant-termination" + }, { "id": "staff-retention-control", "export": "include-in-controller-authorized-tenant-export", @@ -2598,6 +2627,27 @@ "surface": "integration-command", "retentionPolicy": "transient-request" }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidate", + "member": "ExpectedAuthSubjectId", + "surface": "integration-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest", + "member": "ExpectedAuthSubjectId", + "surface": "integration-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffMemberSafetyEvidence", + "member": "AuthSubjectId", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, { "assembly": "BunkFy.Modules.Staff.Domain", "type": "BunkFy.Modules.Staff.Domain.Aggregates.StaffMember", @@ -5600,6 +5650,12 @@ "member": "EventId", "surface": "integration-event" }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCreatedIntegrationEvent", + "member": "EventId", + "surface": "integration-event" + }, { "assembly": "BunkFy.Modules.Staff.Domain", "type": "BunkFy.Modules.Staff.Domain.Events.StaffAuthSubjectChangedDomainEvent", @@ -5791,6 +5847,12 @@ "member": "OccurredAtUtc", "surface": "integration-event" }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCreatedIntegrationEvent", + "member": "OccurredAtUtc", + "surface": "integration-event" + }, { "assembly": "BunkFy.Modules.Staff.Domain", "type": "BunkFy.Modules.Staff.Domain.Events.StaffAuthSubjectChangedDomainEvent", @@ -13319,6 +13381,849 @@ "retentionPolicy": "staff-tenant-termination-export-fragment" } ] + }, + { + "id": "staff.identity-provisioning-anchor.tenant-scope-id", + "dataSubject": "staff", + "classification": "linked-operational", + "sensitivity": "standard", + "purposes": ["tenant-isolation", "identity-provisioning-reconciliation"], + "sources": ["tenant-context"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "staff-identity-provisioning-anchor", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["application-command", "integration-event", "persistence"], + "allowedBoundaries": ["cross-module", "intra-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorRecord", + "member": "ScopeId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor", + "member": "ScopeId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord", + "member": "ScopeId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution", + "member": "ScopeId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCreatedIntegrationEvent", + "member": "ScopeId", + "surface": "integration-event", + "retentionPolicy": "integration-message-journal" + } + ] + }, + { + "id": "staff.identity-provisioning-anchor.source-kind", + "dataSubject": "staff", + "classification": "linked-operational", + "sensitivity": "standard", + "purposes": ["identity-provisioning-reconciliation", "tenant-portability"], + "sources": ["accepted-provisioning-source"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "staff-identity-provisioning-anchor", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["application-command", "application-query", "integration-command", "persistence", "projection-export", "data-rights-export"], + "allowedBoundaries": ["cross-module", "intra-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorRecord", + "member": "SourceKind", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningSourceKey", + "member": "SourceKind", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor", + "member": "SourceKind", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorDataRightsExport", + "member": "SourceKind", + "surface": "data-rights-export", + "retentionPolicy": "staff-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord", + "member": "SourceKind", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidate", + "member": "SourceKind", + "surface": "integration-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidateInspection", + "member": "SourceKind", + "surface": "projection-export", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution", + "member": "SourceKind", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionDataRightsExport", + "member": "SourceKind", + "surface": "data-rights-export", + "retentionPolicy": "staff-data-rights-export-fragment" + } + ] + }, + { + "id": "staff.identity-provisioning-anchor.source-id", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "standard", + "purposes": ["identity-provisioning-reconciliation", "exact-retry", "tenant-portability"], + "sources": ["accepted-provisioning-source"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "staff-identity-provisioning-anchor", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["application-command", "application-query", "integration-command", "integration-event", "persistence", "projection-export", "data-rights-export"], + "allowedBoundaries": ["cross-module", "intra-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Commands.BootstrapStaffIdentityCommand", + "member": "SourceId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorRecord", + "member": "SourceId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningSourceKey", + "member": "SourceId", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityBootstrapRequest", + "member": "SourceId", + "surface": "integration-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor", + "member": "SourceId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorDataRightsExport", + "member": "SourceId", + "surface": "data-rights-export", + "retentionPolicy": "staff-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord", + "member": "SourceId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidate", + "member": "SourceId", + "surface": "integration-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidateInspection", + "member": "SourceId", + "surface": "projection-export", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCreatedIntegrationEvent", + "member": "ApplicationId", + "surface": "integration-event", + "retentionPolicy": "integration-message-journal" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest", + "member": "ApplicationId", + "surface": "integration-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome", + "member": "ApplicationId", + "surface": "projection-export", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorResolutionRequest", + "member": "ApplicationId", + "surface": "integration-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution", + "member": "SourceId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionDataRightsExport", + "member": "SourceId", + "surface": "data-rights-export", + "retentionPolicy": "staff-data-rights-export-fragment" + } + ] + }, + { + "id": "staff.identity-provisioning-anchor.staff-member-id", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "standard", + "purposes": ["staff-record-linkage", "identity-provisioning-reconciliation", "tenant-portability"], + "sources": ["staff-profile"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "staff-identity-provisioning-anchor", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["application-command", "application-query", "integration-command", "integration-event", "persistence", "projection-export", "data-rights-export"], + "allowedBoundaries": ["cross-module", "intra-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorRecord", + "member": "StaffMemberId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor", + "member": "StaffMemberId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorDataRightsExport", + "member": "StaffMemberId", + "surface": "data-rights-export", + "retentionPolicy": "staff-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord", + "member": "StaffMemberId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffMemberSafetyEvidence", + "member": "StaffMemberId", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidate", + "member": "StaffMemberId", + "surface": "integration-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCreatedIntegrationEvent", + "member": "StaffMemberId", + "surface": "integration-event", + "retentionPolicy": "integration-message-journal" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome", + "member": "StaffMemberId", + "surface": "projection-export", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorResolutionRequest", + "member": "StaffMemberId", + "surface": "integration-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution", + "member": "StaffMemberId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionDataRightsExport", + "member": "StaffMemberId", + "surface": "data-rights-export", + "retentionPolicy": "staff-data-rights-export-fragment" + } + ] + }, + { + "id": "staff.identity-provisioning-anchor.anchored-at-utc", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": ["identity-provisioning-reconciliation", "tenant-portability"], + "sources": ["system-clock"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "staff-identity-provisioning-anchor", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["application-command", "persistence", "data-rights-export"], + "allowedBoundaries": ["cross-module", "intra-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorRecord", + "member": "AnchoredAtUtc", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor", + "member": "AnchoredAtUtc", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorDataRightsExport", + "member": "AnchoredAtUtc", + "surface": "data-rights-export", + "retentionPolicy": "staff-data-rights-export-fragment" + } + ] + }, + { + "id": "staff.identity-provisioning-anchor", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": ["tenant-portability", "identity-provisioning-reconciliation"], + "sources": ["staff-identity-provisioning-anchor"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "staff-identity-provisioning-anchor", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["application-command", "application-query", "projection-export", "data-rights-export"], + "allowedBoundaries": ["cross-module", "intra-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Commands.ApplyStaffIdentityProvisioningAnchorsCommand", + "member": "Candidates", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Queries.InspectStaffIdentityProvisioningAnchorsQuery", + "member": "Candidates", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorTenantExport", + "member": "IdentityProvisioningAnchor", + "surface": "data-rights-export", + "retentionPolicy": "staff-tenant-termination-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Queries.ReadStaffWorkspaceOnboardingIdentityAnchorOutcomesQuery", + "member": "Requests", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorInspection", + "member": "Items", + "surface": "projection-export", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "staff.identity-provisioning-anchor-resolution", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": ["tenant-portability", "identity-provisioning-reconciliation", "lifecycle-safety"], + "sources": ["workspace-onboarding-redaction-acknowledgement"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "staff-identity-provisioning-anchor", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["application-command", "data-rights-export"], + "allowedBoundaries": ["cross-module", "intra-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Commands.RecordStaffWorkspaceOnboardingIdentityAnchorResolutionCommand", + "member": "Request", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionTenantExport", + "member": "IdentityProvisioningAnchorResolution", + "surface": "data-rights-export", + "retentionPolicy": "staff-tenant-termination-export-fragment" + } + ] + }, + { + "id": "staff.identity-provisioning-anchor.workspace-application-version", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": ["exact-retry", "redaction-acknowledgement", "tenant-portability"], + "sources": ["workspace-onboarding-aggregate"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "staff-identity-provisioning-anchor", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["application-command", "integration-command", "persistence", "projection-export", "data-rights-export"], + "allowedBoundaries": ["cross-module", "intra-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord", + "member": "WorkspaceApplicationVersion", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorResolutionRequest", + "member": "WorkspaceApplicationVersion", + "surface": "integration-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome", + "member": "WorkspaceApplicationVersion", + "surface": "projection-export", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution", + "member": "WorkspaceApplicationVersion", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionDataRightsExport", + "member": "WorkspaceApplicationVersion", + "surface": "data-rights-export", + "retentionPolicy": "staff-data-rights-export-fragment" + } + ] + }, + { + "id": "staff.identity-provisioning-anchor.resolution-disposition", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": ["redaction-acknowledgement", "lifecycle-safety", "tenant-portability"], + "sources": ["workspace-onboarding-terminal-transition"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "staff-identity-provisioning-anchor", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["application-command", "integration-command", "persistence", "projection-export", "data-rights-export"], + "allowedBoundaries": ["cross-module", "intra-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord", + "member": "Disposition", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorResolutionRequest", + "member": "Disposition", + "surface": "integration-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome", + "member": "ResolutionDisposition", + "surface": "projection-export", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution", + "member": "Disposition", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionDataRightsExport", + "member": "Disposition", + "surface": "data-rights-export", + "retentionPolicy": "staff-data-rights-export-fragment" + } + ] + }, + { + "id": "staff.identity-provisioning-anchor.resolution-event-id", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "standard", + "purposes": ["exact-retry", "redaction-acknowledgement", "tenant-portability"], + "sources": ["staff-identity-provisioning-anchor-writer"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "staff-identity-provisioning-anchor", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["application-command", "integration-command", "integration-event", "persistence", "projection-export", "data-rights-export"], + "allowedBoundaries": ["cross-module", "intra-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorRecord", + "member": "ResolutionEventId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord", + "member": "ResolutionEventId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorResolutionRequest", + "member": "ResolutionEventId", + "surface": "integration-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffOnboardingProvisioningResult", + "member": "ResolutionEventId", + "surface": "projection-export", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome", + "member": "ResolutionEventId", + "surface": "projection-export", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCreatedIntegrationEvent", + "member": "ResolutionEventId", + "surface": "integration-event", + "retentionPolicy": "integration-message-journal" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor", + "member": "ResolutionEventId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution", + "member": "ResolutionEventId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorDataRightsExport", + "member": "ResolutionEventId", + "surface": "data-rights-export", + "retentionPolicy": "staff-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionDataRightsExport", + "member": "ResolutionEventId", + "surface": "data-rights-export", + "retentionPolicy": "staff-data-rights-export-fragment" + } + ] + }, + { + "id": "staff.identity-provisioning-anchor.resolved-at-utc", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": ["redaction-acknowledgement", "lifecycle-safety", "tenant-portability"], + "sources": ["workspace-onboarding-resolution-event"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "staff-identity-provisioning-anchor", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["application-command", "integration-command", "persistence", "data-rights-export"], + "allowedBoundaries": ["cross-module", "intra-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Application", + "type": "BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord", + "member": "ResolvedAtUtc", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorResolutionRequest", + "member": "ResolvedAtUtc", + "surface": "integration-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution", + "member": "ResolvedAtUtc", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Staff.Persistence", + "type": "BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionDataRightsExport", + "member": "ResolvedAtUtc", + "surface": "data-rights-export", + "retentionPolicy": "staff-data-rights-export-fragment" + } + ] + }, + { + "id": "staff.identity-provisioning-anchor.outcome-status", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": ["identity-provisioning-reconciliation", "lifecycle-safety"], + "sources": ["staff-identity-provisioning-anchor-state"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "transient-response", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["projection-export"], + "allowedBoundaries": ["cross-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome", + "member": "Status", + "surface": "projection-export", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "staff.identity-provisioning-anchor.target-lifecycle", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": ["identity-provisioning-reconciliation", "access-non-resurrection"], + "sources": ["staff-profile-lifecycle"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "transient-response", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["projection-export"], + "allowedBoundaries": ["cross-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome", + "member": "TargetLifecycle", + "surface": "projection-export", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "staff.identity-provisioning-anchor.subject-match", + "dataSubject": "staff", + "classification": "linked-operational", + "sensitivity": "elevated", + "purposes": ["identity-provisioning-reconciliation", "access-non-resurrection"], + "sources": ["transient-auth-subject-comparison"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "transient-response", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["projection-export"], + "allowedBoundaries": ["cross-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome", + "member": "SubjectMatch", + "surface": "projection-export", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "staff.identity-provisioning-anchor.cutover-disposition", + "dataSubject": "staff", + "classification": "linked-operational", + "sensitivity": "standard", + "purposes": ["identity-provisioning-cutover", "conflict-remediation"], + "sources": ["staff-identity-provisioning-anchor-inspection"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "transient-response", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["projection-export"], + "allowedBoundaries": ["cross-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidateInspection", + "member": "Disposition", + "surface": "projection-export", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "staff.identity-provisioning-anchor.reviewed-erased-target", + "dataSubject": "staff", + "classification": "linked-operational", + "sensitivity": "elevated", + "purposes": ["identity-provisioning-cutover", "reviewed-erasure-evidence"], + "sources": ["operator-reviewed-cutover-manifest"], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "staff-identity-provisioning-anchors", + "countryPolicyKey": "staff.identity-provisioning-anchor", + "retentionPolicy": "transient-request", + "rightsPolicy": "staff-identity-provisioning-anchor-control", + "allowedSurfaces": ["integration-command"], + "allowedBoundaries": ["cross-module"], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Staff.Contracts", + "type": "BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidate", + "member": "ReviewedErasedTarget", + "surface": "integration-command", + "retentionPolicy": "transient-request" + } + ] } ] } diff --git a/src/Modules/Staff/docs/personal-data-inventory.v1.md b/src/Modules/Staff/docs/personal-data-inventory.v1.md index 87786902..60a75e01 100644 --- a/src/Modules/Staff/docs/personal-data-inventory.v1.md +++ b/src/Modules/Staff/docs/personal-data-inventory.v1.md @@ -1,4 +1,4 @@ -# staff Personal-Data Inventory v19 +# staff Personal-Data Inventory v20 Generated from `staff.personal-data` schema v1. Catalogue approval: `engineering-default`. @@ -17,6 +17,7 @@ Engineering metadata is not legal or country-launch approval. | staff-directory-request | tenant-property-authorized-request | permission:staff.read | permission:staff.read | | staff-employment-governance | tenant-sensitive-employment-governance | permission:staff.employment-governance.manage
permission:staff.sensitive-profile.read
system:staff.employment-governance | permission:staff.employment-governance.manage
system:staff.employment-governance | | staff-event-metadata | tenant-internal-messaging | system:authorized-module-consumer | system:staff-outbox | +| staff-identity-provisioning-anchors | tenant-staff-identity-provisioning | permission:staff.sensitive-profile.read
subject:self
system:staff.data-rights-export
system:staff.tenant-export
system:workspace-staff-onboarding | system:organizations.owner-bootstrap
system:workspace-staff-onboarding | | staff-internal | tenant-internal | system:staff-projection-runtime | system:staff-projection-runtime | | staff-member-mutation-operations | tenant-staff-authorized-replay-journal | permission:staff.sensitive-profile.read
subject:self
system:staff.data-rights-export
system:staff.tenant-export | permission:staff.assign-properties
permission:staff.manage
permission:staff.manage-lifecycle
subject:self
system:staff.anonymisation
system:staff.retention
system:workspace-staff-onboarding | | staff-processing-restrictions | tenant-approved-data-rights-operation | permission:data-rights.execute
system:staff.authorized-audience-reader
system:staff.processing-restriction | permission:data-rights.execute
system:staff.processing-restriction | @@ -37,6 +38,7 @@ Engineering metadata is not legal or country-launch approval. | staff-data-rights-export-fragment | engineering-default | authorized-staff-export-assembly | caller-completion-or-discard | not-applicable | | staff-employment-governance-current | engineering-default | staff-employment-governance-configured | approved-erasure-or-employment-retention-completed | pause-approved-erasure | | staff-employment-governance-receipt | engineering-default | staff-employment-governance-change-completed | approved-audit-retention-expired-or-tenant-termination | pause-approved-disposal | +| staff-identity-provisioning-anchor | engineering-default | staff-identity-provisioning-anchored | tenant-termination | retain-durable-provisioning-coordinate | | staff-member-mutation-operation-journal | engineering-default | staff-member-mutation-completed | approved-staff-anonymisation-or-employment-retention-or-tenant-termination | delete-with-owner-profile-disposition | | staff-operation-serialization | engineering-default | first-serialized-staff-operation | approved-erasure-or-employment-retention-completed | retain-while-staff-operation-is-active | | staff-processing-restriction-lifecycle | engineering-default | staff-profile-created | approved-erasure-or-tenant-termination | pause-approved-erasure | @@ -60,6 +62,7 @@ Engineering metadata is not legal or country-launch approval. | staff-employment-governance-accountability | include-minimum-proof-in-controller-authorized-tenant-export | immutable-append-superseding-receipt | retain-minimum-policy-change-proof | pseudonymize-subject-while-preserving-operation-proof | | staff-employment-governance-control | include-in-authorized-staff-or-tenant-export | replace-through-versioned-governance-workflow | retain-minimum-current-policy-evidence | dispose-after-approved-employment-retention | | staff-employment-history | include-in-authorized-staff-or-tenant-export | append-corrective-employment-action | suppress-non-required-operational-use | anonymize-or-delete-after-approved-employment-retention | +| staff-identity-provisioning-anchor-control | include-in-authorized-staff-or-tenant-export | immutable-provisioning-coordinate | retain-for-identity-provisioning-reconciliation | retain-pseudonymous-linkage-until-tenant-termination | | staff-member-mutation-operation-replay | include-in-authorized-staff-or-tenant-export | immutable-replay-coordinate | retain-for-exact-idempotent-replay | delete-with-approved-staff-anonymisation | | staff-operation-serialization-control | not-in-current-subject-export | system-managed-serialization-coordinate | retain-while-staff-operation-is-active | dispose-with-approved-staff-lifecycle | | staff-processing-restriction-control | include-current-state-in-authorized-staff-or-tenant-export | change-only-through-approved-restriction-workflow | enforce-effective-processing-restriction | remove-with-approved-subject-disposition | @@ -190,6 +193,22 @@ Engineering metadata is not legal or country-launch approval. | staff.governance.tenant-scope-id | staff | linked-operational | standard | employment-governance
policy-evidence | staff-owner-workflow | staff | customer-controller-bunk-fy-processor | staff-employment-governance | staff.governance.tenant-scope-id | staff-employment-governance-current | staff-employment-governance-control | persistence | intra-module | engineering-default | | staff.governance.transfer-profile-id | staff | linked-operational | standard | employment-governance
policy-evidence | staff-owner-workflow | staff | customer-controller-bunk-fy-processor | staff-employment-governance | staff.governance.transfer-profile-id | staff-employment-governance-current | staff-employment-governance-control | admin-output
api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module
support | engineering-default | | staff.governance.version | staff | linked-operational | standard | employment-governance
policy-evidence | staff-owner-workflow | staff | customer-controller-bunk-fy-processor | staff-employment-governance | staff.governance.version | staff-employment-governance-current | staff-employment-governance-control | admin-output
api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module
support | engineering-default | +| staff.identity-provisioning-anchor | staff | pseudonymous-identifier | elevated | identity-provisioning-reconciliation
tenant-portability | staff-identity-provisioning-anchor | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | staff-identity-provisioning-anchor | staff-identity-provisioning-anchor-control | application-command
application-query
data-rights-export
projection-export | cross-module
intra-module | engineering-default | +| staff.identity-provisioning-anchor-resolution | staff | pseudonymous-identifier | elevated | identity-provisioning-reconciliation
lifecycle-safety
tenant-portability | workspace-onboarding-redaction-acknowledgement | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | staff-identity-provisioning-anchor | staff-identity-provisioning-anchor-control | application-command
data-rights-export | cross-module
intra-module | engineering-default | +| staff.identity-provisioning-anchor.anchored-at-utc | staff | lifecycle | standard | identity-provisioning-reconciliation
tenant-portability | system-clock | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | staff-identity-provisioning-anchor | staff-identity-provisioning-anchor-control | application-command
data-rights-export
persistence | cross-module
intra-module | engineering-default | +| staff.identity-provisioning-anchor.cutover-disposition | staff | linked-operational | standard | conflict-remediation
identity-provisioning-cutover | staff-identity-provisioning-anchor-inspection | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | transient-response | staff-identity-provisioning-anchor-control | projection-export | cross-module | engineering-default | +| staff.identity-provisioning-anchor.outcome-status | staff | lifecycle | standard | identity-provisioning-reconciliation
lifecycle-safety | staff-identity-provisioning-anchor-state | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | transient-response | staff-identity-provisioning-anchor-control | projection-export | cross-module | engineering-default | +| staff.identity-provisioning-anchor.resolution-disposition | staff | lifecycle | standard | lifecycle-safety
redaction-acknowledgement
tenant-portability | workspace-onboarding-terminal-transition | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | staff-identity-provisioning-anchor | staff-identity-provisioning-anchor-control | application-command
data-rights-export
integration-command
persistence
projection-export | cross-module
intra-module | engineering-default | +| staff.identity-provisioning-anchor.resolution-event-id | staff | pseudonymous-identifier | standard | exact-retry
redaction-acknowledgement
tenant-portability | staff-identity-provisioning-anchor-writer | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | staff-identity-provisioning-anchor | staff-identity-provisioning-anchor-control | application-command
data-rights-export
integration-command
integration-event
persistence
projection-export | cross-module
intra-module | engineering-default | +| staff.identity-provisioning-anchor.resolved-at-utc | staff | lifecycle | standard | lifecycle-safety
redaction-acknowledgement
tenant-portability | workspace-onboarding-resolution-event | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | staff-identity-provisioning-anchor | staff-identity-provisioning-anchor-control | application-command
data-rights-export
integration-command
persistence | cross-module
intra-module | engineering-default | +| staff.identity-provisioning-anchor.reviewed-erased-target | staff | linked-operational | elevated | identity-provisioning-cutover
reviewed-erasure-evidence | operator-reviewed-cutover-manifest | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | transient-request | staff-identity-provisioning-anchor-control | integration-command | cross-module | engineering-default | +| staff.identity-provisioning-anchor.source-id | staff | pseudonymous-identifier | standard | exact-retry
identity-provisioning-reconciliation
tenant-portability | accepted-provisioning-source | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | staff-identity-provisioning-anchor | staff-identity-provisioning-anchor-control | application-command
application-query
data-rights-export
integration-command
integration-event
persistence
projection-export | cross-module
intra-module | engineering-default | +| staff.identity-provisioning-anchor.source-kind | staff | linked-operational | standard | identity-provisioning-reconciliation
tenant-portability | accepted-provisioning-source | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | staff-identity-provisioning-anchor | staff-identity-provisioning-anchor-control | application-command
application-query
data-rights-export
integration-command
persistence
projection-export | cross-module
intra-module | engineering-default | +| staff.identity-provisioning-anchor.staff-member-id | staff | pseudonymous-identifier | standard | identity-provisioning-reconciliation
staff-record-linkage
tenant-portability | staff-profile | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | staff-identity-provisioning-anchor | staff-identity-provisioning-anchor-control | application-command
application-query
data-rights-export
integration-command
integration-event
persistence
projection-export | cross-module
intra-module | engineering-default | +| staff.identity-provisioning-anchor.subject-match | staff | linked-operational | elevated | access-non-resurrection
identity-provisioning-reconciliation | transient-auth-subject-comparison | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | transient-response | staff-identity-provisioning-anchor-control | projection-export | cross-module | engineering-default | +| staff.identity-provisioning-anchor.target-lifecycle | staff | lifecycle | standard | access-non-resurrection
identity-provisioning-reconciliation | staff-profile-lifecycle | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | transient-response | staff-identity-provisioning-anchor-control | projection-export | cross-module | engineering-default | +| staff.identity-provisioning-anchor.tenant-scope-id | staff | linked-operational | standard | identity-provisioning-reconciliation
tenant-isolation | tenant-context | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | staff-identity-provisioning-anchor | staff-identity-provisioning-anchor-control | application-command
integration-event
persistence | cross-module
intra-module | engineering-default | +| staff.identity-provisioning-anchor.workspace-application-version | staff | lifecycle | standard | exact-retry
redaction-acknowledgement
tenant-portability | workspace-onboarding-aggregate | staff | customer-controller-bunk-fy-processor | staff-identity-provisioning-anchors | staff.identity-provisioning-anchor | staff-identity-provisioning-anchor | staff-identity-provisioning-anchor-control | application-command
data-rights-export
integration-command
persistence
projection-export | cross-module
intra-module | engineering-default | | staff.job-title | staff | linked-operational | standard | operational-directory | staff-entry | staff | customer-controller-bunk-fy-processor | staff-directory | staff.profile.job | staff-profile-lifecycle | staff-profile-editable | admin-input
admin-output
api-input
api-response
application-command
data-rights-export
integration-command
persistence | cross-module
customer-api
intra-module
support | engineering-default | | staff.legal-name | staff | direct-identifier | elevated | employment-administration | staff-entry | staff | customer-controller-bunk-fy-processor | staff-sensitive-profile | staff.profile.legal-identity | staff-profile-lifecycle | staff-profile-editable | admin-input
admin-output
api-input
api-response
application-command
data-rights-export
integration-command
persistence
search-index | cross-module
customer-api
intra-module
support | engineering-default | | staff.member-mutation-operation | staff | pseudonymous-identifier | elevated | exact-retry
mutation-audit
tenant-portability | staff-member-mutation-operation-journal | staff | customer-controller-bunk-fy-processor | staff-member-mutation-operations | staff.member-mutation-operation | staff-member-mutation-operation-journal | staff-member-mutation-operation-replay | data-rights-export | cross-module | engineering-default | @@ -405,13 +424,16 @@ Engineering metadata is not legal or country-launch approval. | staff.auth-subject-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Commands.ProvisionStaffOnboardingCommand | AuthSubjectId | application-command | transient-request | | staff.auth-subject-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Commands.SetStaffAuthSubjectCommand | AuthSubjectId | application-command | transient-request | | staff.auth-subject-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Commands.UpdateCurrentStaffMemberCommand | AuthSubjectId | application-command | transient-request | +| staff.auth-subject-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffMemberSafetyEvidence | AuthSubjectId | application-query | transient-request | | staff.auth-subject-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Queries.GetCurrentStaffMemberQuery | AuthSubjectId | application-query | transient-request | | staff.auth-subject-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffAnonymisationRestoreState | AuthSubjectId | projection-export | transient-response | | staff.auth-subject-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityBootstrapRequest | AuthSubjectId | integration-command | transient-request | +| staff.auth-subject-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidate | ExpectedAuthSubjectId | integration-command | transient-request | | staff.auth-subject-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffMemberDto | AuthSubjectId | api-response | transient-response | | staff.auth-subject-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffMemberDto | AuthSubjectId | admin-output | transient-response | | staff.auth-subject-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffNotificationRecipient | AuthSubjectId | projection-export | transient-response | | staff.auth-subject-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffOnboardingProvisioningRequest | AuthSubjectId | integration-command | transient-request | +| staff.auth-subject-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest | ExpectedAuthSubjectId | integration-command | transient-request | | staff.auth-subject-id | BunkFy.Modules.Staff.Domain | BunkFy.Modules.Staff.Domain.Aggregates.StaffMember | AuthSubjectId | persistence | staff-profile-lifecycle | | staff.auth-subject-id | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffProfileDataRightsExport | AuthSubjectId | data-rights-export | staff-data-rights-export-fragment | | staff.change-reason | BunkFy.Modules.Staff.AdminApi | BunkFy.Modules.Staff.AdminApi.StaffAdminApiModule+StaffDepartureRequest | Reason | admin-input | transient-request | @@ -669,6 +691,7 @@ Engineering metadata is not legal or country-launch approval. | staff.event.effective-to | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffPropertyAssignmentChangedIntegrationEvent | EffectiveTo | integration-event | integration-message-journal | | staff.event.effective-to | BunkFy.Modules.Staff.Domain | BunkFy.Modules.Staff.Domain.Events.StaffPropertyAssignmentChangedDomainEvent | EffectiveTo | domain-event | transient-request | | staff.event.event-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffAuthSubjectChangedIntegrationEvent | EventId | integration-event | integration-message-journal | +| staff.event.event-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCreatedIntegrationEvent | EventId | integration-event | integration-message-journal | | staff.event.event-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffMemberAnonymisedIntegrationEvent | EventId | integration-event | integration-message-journal | | staff.event.event-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffMemberCreatedIntegrationEvent | EventId | integration-event | integration-message-journal | | staff.event.event-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffMemberLifecycleChangedIntegrationEvent | EventId | integration-event | integration-message-journal | @@ -685,6 +708,7 @@ Engineering metadata is not legal or country-launch approval. | staff.event.is-primary | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffPropertyAssignmentChangedIntegrationEvent | IsPrimary | integration-event | integration-message-journal | | staff.event.is-primary | BunkFy.Modules.Staff.Domain | BunkFy.Modules.Staff.Domain.Events.StaffPropertyAssignmentChangedDomainEvent | IsPrimary | domain-event | transient-request | | staff.event.occurred-at-utc | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffAuthSubjectChangedIntegrationEvent | OccurredAtUtc | integration-event | integration-message-journal | +| staff.event.occurred-at-utc | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCreatedIntegrationEvent | OccurredAtUtc | integration-event | integration-message-journal | | staff.event.occurred-at-utc | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffMemberAnonymisedIntegrationEvent | OccurredAtUtc | integration-event | integration-message-journal | | staff.event.occurred-at-utc | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffMemberCreatedIntegrationEvent | OccurredAtUtc | integration-event | integration-message-journal | | staff.event.occurred-at-utc | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffMemberLifecycleChangedIntegrationEvent | OccurredAtUtc | integration-event | integration-message-journal | @@ -882,6 +906,85 @@ Engineering metadata is not legal or country-launch approval. | staff.governance.version | BunkFy.Modules.Staff.Domain | BunkFy.Modules.Staff.Domain.Governance.StaffEmploymentGovernanceChangeReceipt | PreviousGovernanceVersion | persistence | staff-employment-governance-receipt | | staff.governance.version | BunkFy.Modules.Staff.Domain | BunkFy.Modules.Staff.Domain.Governance.StaffEmploymentGovernanceChangeReceipt | ResultingGovernanceVersion | persistence | staff-employment-governance-receipt | | staff.governance.version | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffEmploymentGovernanceDataRightsExport | Version | data-rights-export | staff-data-rights-export-fragment | +| staff.identity-provisioning-anchor | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Commands.ApplyStaffIdentityProvisioningAnchorsCommand | Candidates | application-command | transient-request | +| staff.identity-provisioning-anchor | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Queries.InspectStaffIdentityProvisioningAnchorsQuery | Candidates | application-query | transient-request | +| staff.identity-provisioning-anchor | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Queries.ReadStaffWorkspaceOnboardingIdentityAnchorOutcomesQuery | Requests | application-query | transient-request | +| staff.identity-provisioning-anchor | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorInspection | Items | projection-export | transient-response | +| staff.identity-provisioning-anchor | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorTenantExport | IdentityProvisioningAnchor | data-rights-export | staff-tenant-termination-export-fragment | +| staff.identity-provisioning-anchor-resolution | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Commands.RecordStaffWorkspaceOnboardingIdentityAnchorResolutionCommand | Request | application-command | transient-request | +| staff.identity-provisioning-anchor-resolution | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionTenantExport | IdentityProvisioningAnchorResolution | data-rights-export | staff-tenant-termination-export-fragment | +| staff.identity-provisioning-anchor.anchored-at-utc | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorRecord | AnchoredAtUtc | application-command | transient-request | +| staff.identity-provisioning-anchor.anchored-at-utc | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorDataRightsExport | AnchoredAtUtc | data-rights-export | staff-data-rights-export-fragment | +| staff.identity-provisioning-anchor.anchored-at-utc | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor | AnchoredAtUtc | persistence | staff-identity-provisioning-anchor | +| staff.identity-provisioning-anchor.cutover-disposition | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidateInspection | Disposition | projection-export | transient-response | +| staff.identity-provisioning-anchor.outcome-status | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome | Status | projection-export | transient-response | +| staff.identity-provisioning-anchor.resolution-disposition | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord | Disposition | application-command | transient-request | +| staff.identity-provisioning-anchor.resolution-disposition | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome | ResolutionDisposition | projection-export | transient-response | +| staff.identity-provisioning-anchor.resolution-disposition | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorResolutionRequest | Disposition | integration-command | transient-request | +| staff.identity-provisioning-anchor.resolution-disposition | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionDataRightsExport | Disposition | data-rights-export | staff-data-rights-export-fragment | +| staff.identity-provisioning-anchor.resolution-disposition | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution | Disposition | persistence | staff-identity-provisioning-anchor | +| staff.identity-provisioning-anchor.resolution-event-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorRecord | ResolutionEventId | application-command | transient-request | +| staff.identity-provisioning-anchor.resolution-event-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord | ResolutionEventId | application-command | transient-request | +| staff.identity-provisioning-anchor.resolution-event-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCreatedIntegrationEvent | ResolutionEventId | integration-event | integration-message-journal | +| staff.identity-provisioning-anchor.resolution-event-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffOnboardingProvisioningResult | ResolutionEventId | projection-export | transient-response | +| staff.identity-provisioning-anchor.resolution-event-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome | ResolutionEventId | projection-export | transient-response | +| staff.identity-provisioning-anchor.resolution-event-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorResolutionRequest | ResolutionEventId | integration-command | transient-request | +| staff.identity-provisioning-anchor.resolution-event-id | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorDataRightsExport | ResolutionEventId | data-rights-export | staff-data-rights-export-fragment | +| staff.identity-provisioning-anchor.resolution-event-id | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionDataRightsExport | ResolutionEventId | data-rights-export | staff-data-rights-export-fragment | +| staff.identity-provisioning-anchor.resolution-event-id | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor | ResolutionEventId | persistence | staff-identity-provisioning-anchor | +| staff.identity-provisioning-anchor.resolution-event-id | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution | ResolutionEventId | persistence | staff-identity-provisioning-anchor | +| staff.identity-provisioning-anchor.resolved-at-utc | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord | ResolvedAtUtc | application-command | transient-request | +| staff.identity-provisioning-anchor.resolved-at-utc | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorResolutionRequest | ResolvedAtUtc | integration-command | transient-request | +| staff.identity-provisioning-anchor.resolved-at-utc | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionDataRightsExport | ResolvedAtUtc | data-rights-export | staff-data-rights-export-fragment | +| staff.identity-provisioning-anchor.resolved-at-utc | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution | ResolvedAtUtc | persistence | staff-identity-provisioning-anchor | +| staff.identity-provisioning-anchor.reviewed-erased-target | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidate | ReviewedErasedTarget | integration-command | transient-request | +| staff.identity-provisioning-anchor.source-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Commands.BootstrapStaffIdentityCommand | SourceId | application-command | transient-request | +| staff.identity-provisioning-anchor.source-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorRecord | SourceId | application-command | transient-request | +| staff.identity-provisioning-anchor.source-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord | SourceId | application-command | transient-request | +| staff.identity-provisioning-anchor.source-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningSourceKey | SourceId | application-query | transient-request | +| staff.identity-provisioning-anchor.source-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityBootstrapRequest | SourceId | integration-command | transient-request | +| staff.identity-provisioning-anchor.source-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidate | SourceId | integration-command | transient-request | +| staff.identity-provisioning-anchor.source-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidateInspection | SourceId | projection-export | transient-response | +| staff.identity-provisioning-anchor.source-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCreatedIntegrationEvent | ApplicationId | integration-event | integration-message-journal | +| staff.identity-provisioning-anchor.source-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome | ApplicationId | projection-export | transient-response | +| staff.identity-provisioning-anchor.source-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest | ApplicationId | integration-command | transient-request | +| staff.identity-provisioning-anchor.source-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorResolutionRequest | ApplicationId | integration-command | transient-request | +| staff.identity-provisioning-anchor.source-id | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorDataRightsExport | SourceId | data-rights-export | staff-data-rights-export-fragment | +| staff.identity-provisioning-anchor.source-id | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionDataRightsExport | SourceId | data-rights-export | staff-data-rights-export-fragment | +| staff.identity-provisioning-anchor.source-id | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor | SourceId | persistence | staff-identity-provisioning-anchor | +| staff.identity-provisioning-anchor.source-id | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution | SourceId | persistence | staff-identity-provisioning-anchor | +| staff.identity-provisioning-anchor.source-kind | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorRecord | SourceKind | application-command | transient-request | +| staff.identity-provisioning-anchor.source-kind | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord | SourceKind | application-command | transient-request | +| staff.identity-provisioning-anchor.source-kind | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningSourceKey | SourceKind | application-query | transient-request | +| staff.identity-provisioning-anchor.source-kind | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidate | SourceKind | integration-command | transient-request | +| staff.identity-provisioning-anchor.source-kind | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidateInspection | SourceKind | projection-export | transient-response | +| staff.identity-provisioning-anchor.source-kind | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorDataRightsExport | SourceKind | data-rights-export | staff-data-rights-export-fragment | +| staff.identity-provisioning-anchor.source-kind | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionDataRightsExport | SourceKind | data-rights-export | staff-data-rights-export-fragment | +| staff.identity-provisioning-anchor.source-kind | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor | SourceKind | persistence | staff-identity-provisioning-anchor | +| staff.identity-provisioning-anchor.source-kind | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution | SourceKind | persistence | staff-identity-provisioning-anchor | +| staff.identity-provisioning-anchor.staff-member-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorRecord | StaffMemberId | application-command | transient-request | +| staff.identity-provisioning-anchor.staff-member-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord | StaffMemberId | application-command | transient-request | +| staff.identity-provisioning-anchor.staff-member-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffMemberSafetyEvidence | StaffMemberId | application-query | transient-request | +| staff.identity-provisioning-anchor.staff-member-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCandidate | StaffMemberId | integration-command | transient-request | +| staff.identity-provisioning-anchor.staff-member-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCreatedIntegrationEvent | StaffMemberId | integration-event | integration-message-journal | +| staff.identity-provisioning-anchor.staff-member-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome | StaffMemberId | projection-export | transient-response | +| staff.identity-provisioning-anchor.staff-member-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorResolutionRequest | StaffMemberId | integration-command | transient-request | +| staff.identity-provisioning-anchor.staff-member-id | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorDataRightsExport | StaffMemberId | data-rights-export | staff-data-rights-export-fragment | +| staff.identity-provisioning-anchor.staff-member-id | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionDataRightsExport | StaffMemberId | data-rights-export | staff-data-rights-export-fragment | +| staff.identity-provisioning-anchor.staff-member-id | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor | StaffMemberId | persistence | staff-identity-provisioning-anchor | +| staff.identity-provisioning-anchor.staff-member-id | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution | StaffMemberId | persistence | staff-identity-provisioning-anchor | +| staff.identity-provisioning-anchor.subject-match | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome | SubjectMatch | projection-export | transient-response | +| staff.identity-provisioning-anchor.target-lifecycle | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome | TargetLifecycle | projection-export | transient-response | +| staff.identity-provisioning-anchor.tenant-scope-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorRecord | ScopeId | application-command | transient-request | +| staff.identity-provisioning-anchor.tenant-scope-id | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord | ScopeId | application-command | transient-request | +| staff.identity-provisioning-anchor.tenant-scope-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCreatedIntegrationEvent | ScopeId | integration-event | integration-message-journal | +| staff.identity-provisioning-anchor.tenant-scope-id | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchor | ScopeId | persistence | staff-identity-provisioning-anchor | +| staff.identity-provisioning-anchor.tenant-scope-id | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution | ScopeId | persistence | staff-identity-provisioning-anchor | +| staff.identity-provisioning-anchor.workspace-application-version | BunkFy.Modules.Staff.Application | BunkFy.Modules.Staff.Application.Ports.StaffIdentityProvisioningAnchorResolutionRecord | WorkspaceApplicationVersion | application-command | transient-request | +| staff.identity-provisioning-anchor.workspace-application-version | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorOutcome | WorkspaceApplicationVersion | projection-export | transient-response | +| staff.identity-provisioning-anchor.workspace-application-version | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffWorkspaceOnboardingIdentityAnchorResolutionRequest | WorkspaceApplicationVersion | integration-command | transient-request | +| staff.identity-provisioning-anchor.workspace-application-version | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.Repositories.StaffIdentityProvisioningAnchorResolutionDataRightsExport | WorkspaceApplicationVersion | data-rights-export | staff-data-rights-export-fragment | +| staff.identity-provisioning-anchor.workspace-application-version | BunkFy.Modules.Staff.Persistence | BunkFy.Modules.Staff.Persistence.StaffIdentityProvisioningAnchorResolution | WorkspaceApplicationVersion | persistence | staff-identity-provisioning-anchor | | staff.job-title | BunkFy.Modules.Staff.AdminApi | BunkFy.Modules.Staff.AdminApi.StaffAdminApiModule+StaffProfileUpdateRequest | JobTitle | admin-input | transient-request | | staff.job-title | BunkFy.Modules.Staff.AdminApi | BunkFy.Modules.Staff.AdminApi.StaffAdminApiModule+StaffProfileWriteRequest | JobTitle | admin-input | transient-request | | staff.job-title | BunkFy.Modules.Staff.Api | BunkFy.Modules.Staff.Api.Requests.StaffDataRightsCorrectionRequest | JobTitle | api-input | transient-request | diff --git a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Api/StaffApiSecurityTests.cs b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Api/StaffApiSecurityTests.cs index 72ffe8d1..103a5b71 100644 --- a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Api/StaffApiSecurityTests.cs +++ b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Api/StaffApiSecurityTests.cs @@ -110,7 +110,9 @@ public void Unsafe_account_link_transitions_are_http_conflicts() [ StaffApplicationErrors.AuthSubjectUnlinkRequiresSuspension, StaffApplicationErrors.AuthSubjectReplacementRequiresUnlink, - StaffApplicationErrors.AuthSubjectLinkRequiresActive + StaffApplicationErrors.AuthSubjectLinkRequiresActive, + StaffApplicationErrors.IdentityAnchorResolutionRequired, + StaffApplicationErrors.IdentityAnchorAccessClosureRequired ]; foreach (Error error in errors) diff --git a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/ApplyStaffAnonymisationCommandHandlerTests.cs b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/ApplyStaffAnonymisationCommandHandlerTests.cs index 8dd0dad6..3dab289c 100644 --- a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/ApplyStaffAnonymisationCommandHandlerTests.cs +++ b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/ApplyStaffAnonymisationCommandHandlerTests.cs @@ -172,6 +172,30 @@ await handler.HandleAsync( Assert.Equal(1, scenario.ApprovalGate.CallCount); } + [Fact] + public async Task Unresolved_workspace_anchor_blocks_anonymisation() + { + Scenario scenario = CreateScenario(); + ApplyStaffAnonymisationCommandHandler handler = + scenario.CreateHandler( + new QueueIdGenerator(), + new StubStaffIdentityProvisioningAnchorResolutionRepository( + hasUnresolved: true)); + + Result result = + await handler.HandleAsync( + scenario.Command, + CancellationToken.None); + + Assert.Equal( + StaffApplicationErrors.IdentityAnchorResolutionRequired, + result.Error); + Assert.Equal(StaffMemberState.Departed, scenario.Member.Status); + Assert.Null(scenario.Anonymisation.Receipt); + Assert.Equal(0, scenario.MemberMutationOperations.DeleteCount); + Assert.Equal(1, scenario.OperationLock.AcquireCount); + } + private static Scenario CreateScenario( long evidenceLockRevision = SelectedLockRevision) { @@ -328,7 +352,9 @@ private sealed record Scenario( RecordingApprovalGate ApprovalGate) { public ApplyStaffAnonymisationCommandHandler CreateHandler( - IIdGenerator ids) => + IIdGenerator ids, + IStaffIdentityProvisioningAnchorResolutionRepository? + resolutions = null) => new( new StubStaffMemberRepository(this.Member), new StubGovernanceRepository(this.Governance), @@ -337,6 +363,8 @@ public ApplyStaffAnonymisationCommandHandler CreateHandler( this.OperationLock, this.Anonymisation, this.MemberMutationOperations, + resolutions ?? + new StubStaffIdentityProvisioningAnchorResolutionRepository(), this.ApprovalGate, new TestScopeContext(), new TestClock(), diff --git a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/ApplyStaffDataRightsCorrectionCommandHandlerTests.cs b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/ApplyStaffDataRightsCorrectionCommandHandlerTests.cs index 141aa5c0..9bf5bb7e 100644 --- a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/ApplyStaffDataRightsCorrectionCommandHandlerTests.cs +++ b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/ApplyStaffDataRightsCorrectionCommandHandlerTests.cs @@ -319,6 +319,11 @@ public Task AddAsync( CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetForSafetyTransitionByAuthSubjectAsync( + string authSubjectId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + public Task GetByAuthSubjectAsync( string authSubjectId, CancellationToken cancellationToken) => diff --git a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/ApplyStaffRetentionCommandHandlerTests.cs b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/ApplyStaffRetentionCommandHandlerTests.cs index 906ee3e8..813dbe8b 100644 --- a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/ApplyStaffRetentionCommandHandlerTests.cs +++ b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/ApplyStaffRetentionCommandHandlerTests.cs @@ -123,8 +123,37 @@ await fixture.Handler.HandleAsync( Assert.Equal(0, fixture.Executions.AddProofCount); } + [Fact] + public async Task Unresolved_workspace_anchor_blocks_retention_anonymisation() + { + Fixture fixture = CreateFixture( + new RecordingPrerequisite( + StaffRetentionAnonymisationPrerequisiteResult.Completed()), + hasUnresolvedAnchor: true); + + Result result = + await fixture.Handler.HandleAsync( + new( + fixture.Execution.Id, + fixture.Member.Id, + fixture.Member.Version), + CancellationToken.None); + + Assert.Equal( + StaffRetentionMutationStatus.Failed, + result.Value.Status); + Assert.Equal( + StaffRetentionMutationFailure.IdentityAnchorResolutionRequired, + result.Value.Failure); + Assert.Equal(StaffMemberState.Departed, fixture.Member.Status); + Assert.Equal(0, fixture.Executions.AddProofCount); + Assert.Equal(0, fixture.MemberMutationOperations.DeleteCount); + Assert.Equal(1, fixture.Lock.AcquireCount); + } + private static Fixture CreateFixture( - RecordingPrerequisite? prerequisite) + RecordingPrerequisite? prerequisite, + bool hasUnresolvedAnchor = false) { StaffRetentionPolicyFixture policy = StaffRetentionTestData.CreatePolicy(); @@ -184,6 +213,8 @@ prerequisite is null new StubStaffMemberRepository(member), operationLock, memberMutationOperations, + new StubStaffIdentityProvisioningAnchorResolutionRepository( + hasUnresolvedAnchor), new StaffRetentionEligibilityEvaluator(policy.Registry), prerequisites, new TestScopeContext(member.ScopeId), diff --git a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/StaffCommandHandlerTests.cs b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/StaffCommandHandlerTests.cs index 0b423719..d458d9d1 100644 --- a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/StaffCommandHandlerTests.cs +++ b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/StaffCommandHandlerTests.cs @@ -769,6 +769,127 @@ public async Task Auth_subject_change_supports_suspend_clear_resume_then_link() Assert.Equal(1, members.AuthSubjectExistsCount); } + [Fact] + public async Task Workspace_anchor_blocks_auth_subject_change_even_after_resolution() + { + StaffMember member = CreateMember("user-100"); + Assert.True(member.Suspend( + member.Version, + "user:owner", + "Preparing an account transition", + Guid.NewGuid(), + TestClock.Now).IsSuccess); + RecordingMemberMutationOperations operations = new(); + RecordingIdentityProvisioningAnchors anchors = new(); + anchors.Records.Add(new( + "tenant-a", + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + Guid.NewGuid(), + member.Id, + TestClock.Now, + Guid.NewGuid())); + using ServiceProvider provider = CreateProvider( + new FakeStaffMemberRepository(member), + new FakePropertyProjectionRepository(), + memberMutationOperations: operations, + identityAnchors: anchors); + var handler = provider.GetRequiredService< + ICommandHandler>(); + + Result result = + await handler.HandleAsync( + new SetStaffAuthSubjectCommand( + Guid.NewGuid(), + member.Id, + null, + member.Version, + "user:owner"), + CancellationToken.None); + + Assert.Equal( + StaffApplicationErrors.IdentityAnchorAccessClosureRequired, + result.Error); + Assert.Equal("user-100", member.AuthSubjectId); + Assert.Empty(operations.Records); + } + + [Fact] + public async Task Workspace_anchor_allows_exact_auth_subject_noop_replay() + { + StaffMember member = CreateMember("user-100"); + RecordingIdentityProvisioningAnchors anchors = new(); + anchors.Records.Add(new( + "tenant-a", + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + Guid.NewGuid(), + member.Id, + TestClock.Now, + Guid.NewGuid())); + RecordingMemberMutationOperations operations = new(); + using ServiceProvider provider = CreateProvider( + new FakeStaffMemberRepository(member), + new FakePropertyProjectionRepository(), + identityAnchors: anchors, + memberMutationOperations: operations); + var handler = provider.GetRequiredService>(); + + Result result = + await handler.HandleAsync( + new SetStaffAuthSubjectCommand( + Guid.NewGuid(), + member.Id, + " user-100 ", + member.Version, + "user:owner"), + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal("user-100", member.AuthSubjectId); + Assert.Single(operations.Records); + } + + [Fact] + public async Task Organization_membership_anchor_does_not_block_auth_subject_change() + { + StaffMember member = CreateMember("user-100"); + Assert.True(member.Suspend( + member.Version, + "user:owner", + "Preparing an account transition", + Guid.NewGuid(), + TestClock.Now).IsSuccess); + RecordingIdentityProvisioningAnchors anchors = new(); + anchors.Records.Add(new( + "tenant-a", + StaffIdentityProvisioningSourceKind.OrganizationMembership, + Guid.NewGuid(), + member.Id, + TestClock.Now)); + using ServiceProvider provider = CreateProvider( + new FakeStaffMemberRepository(member), + new FakePropertyProjectionRepository(), + identityAnchors: anchors); + var handler = provider.GetRequiredService>(); + + Result result = + await handler.HandleAsync( + new SetStaffAuthSubjectCommand( + Guid.NewGuid(), + member.Id, + null, + member.Version, + "user:owner"), + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Null(member.AuthSubjectId); + } + [Fact] public async Task Auth_subject_change_replays_normalized_input_without_a_second_event() { @@ -1278,8 +1399,10 @@ public async Task Identity_bootstrap_creates_one_active_staff_profile_for_the_so var handler = provider.GetRequiredService< ICommandHandler>(); Guid operationId = Guid.NewGuid(); + Guid sourceId = Guid.NewGuid(); BootstrapStaffIdentityCommand command = new( operationId, + sourceId, "member-100", "ada@example.test", "ada@example.test", @@ -1296,6 +1419,9 @@ public async Task Identity_bootstrap_creates_one_active_staff_profile_for_the_so Assert.Equal(StaffMemberState.Active, members.AddedMember.Status); Assert.Equal(1, members.AddCount); Assert.Equal(2, creationLock.Acquisitions.Count); + Assert.All( + creationLock.Acquisitions, + acquisition => Assert.Equal(sourceId, acquisition.OperationId)); } [Fact] @@ -1304,6 +1430,7 @@ public void Identity_bootstrap_validator_only_requires_the_replay_envelope() BootstrapStaffIdentityCommandValidator validator = new(); string[] errors = validator.Validate(new BootstrapStaffIdentityCommand( + Guid.NewGuid(), Guid.NewGuid(), " member-100 ", string.Empty, @@ -1321,12 +1448,14 @@ public void Identity_bootstrap_validator_rejects_an_invalid_replay_envelope() string[] emptyOperationErrors = validator.Validate( new BootstrapStaffIdentityCommand( Guid.Empty, + Guid.NewGuid(), "member-100", "Ada Operator", null, "integration:organizations")).ToArray(); string[] emptySubjectErrors = validator.Validate( new BootstrapStaffIdentityCommand( + Guid.NewGuid(), Guid.NewGuid(), " ", "Ada Operator", @@ -1334,6 +1463,7 @@ public void Identity_bootstrap_validator_rejects_an_invalid_replay_envelope() "integration:organizations")).ToArray(); string[] oversizedSubjectErrors = validator.Validate( new BootstrapStaffIdentityCommand( + Guid.NewGuid(), Guid.NewGuid(), new string('s', StaffContractLimits.AuthSubjectIdMaxLength + 1), "Ada Operator", @@ -1364,6 +1494,7 @@ public async Task Identity_bootstrap_exact_replay_ignores_mutation_only_payload( Result result = await handler.HandleAsync( new BootstrapStaffIdentityCommand( operationId, + Guid.NewGuid(), " member-100 ", string.Empty, new string('e', StaffContractLimits.EmailMaxLength + 1), @@ -1387,6 +1518,7 @@ public async Task Identity_bootstrap_existing_identity_ignores_mutation_only_pay Result result = await handler.HandleAsync( new BootstrapStaffIdentityCommand( + Guid.NewGuid(), Guid.NewGuid(), " member-100 ", string.Empty, @@ -1398,6 +1530,67 @@ public async Task Identity_bootstrap_existing_identity_ignores_mutation_only_pay Assert.Equal(0, members.AddCount); } + [Fact] + public async Task Identity_bootstrap_rechecks_subject_after_acquiring_the_existing_target_lock() + { + StaffMember member = CreateMember("member-100"); + FakeStaffMemberRepository members = new(member); + RecordingIdentityProvisioningAnchors anchors = new(); + RecordingOperationLock memberLock = new() + { + OnAcquireById = staffMemberId => + { + if (staffMemberId != member.Id) + { + return; + } + + if (member.AuthSubjectId != "member-100") + { + return; + } + + Assert.True(member.Suspend( + member.Version, + "system:concurrent-transition", + "Concurrent identity transition.", + Guid.NewGuid(), + TestClock.Now.AddMinutes(1)).IsSuccess); + Assert.True(member.SetAuthSubject( + null, + member.Version, + "system:concurrent-transition", + Guid.NewGuid(), + TestClock.Now.AddMinutes(2)).IsSuccess); + } + }; + using ServiceProvider provider = CreateProvider( + members, + new FakePropertyProjectionRepository(), + operationLock: memberLock, + identityAnchors: anchors); + var handler = provider.GetRequiredService< + ICommandHandler>(); + + Result result = await handler.HandleAsync( + new BootstrapStaffIdentityCommand( + Guid.NewGuid(), + Guid.NewGuid(), + "member-100", + "Ada Operator", + "ada@example.test", + "integration:organizations"), + CancellationToken.None); + + Assert.Equal( + StaffApplicationErrors.CreationOperationConflict, + result.Error); + Assert.Empty(anchors.Records); + Assert.Contains( + memberLock.Acquisitions, + acquisition => acquisition.StaffMemberId == member.Id); + } + [Fact] public async Task Identity_bootstrap_new_identity_still_requires_valid_mutation_payload() { @@ -1412,6 +1605,7 @@ public async Task Identity_bootstrap_new_identity_still_requires_valid_mutation_ Result invalidProfile = await handler.HandleAsync( new BootstrapStaffIdentityCommand( + Guid.NewGuid(), Guid.NewGuid(), "member-profile", string.Empty, @@ -1420,6 +1614,7 @@ public async Task Identity_bootstrap_new_identity_still_requires_valid_mutation_ CancellationToken.None); Result invalidActor = await handler.HandleAsync( new BootstrapStaffIdentityCommand( + Guid.NewGuid(), Guid.NewGuid(), "member-actor", "Ada Operator", @@ -1463,6 +1658,7 @@ public async Task Identity_bootstrap_preserves_existing_profile_and_employment_s Result result = await handler.HandleAsync( new BootstrapStaffIdentityCommand( + Guid.NewGuid(), Guid.NewGuid(), "member-100", "Stale Owner Name", @@ -1493,6 +1689,7 @@ public async Task Identity_bootstrap_acknowledges_a_hidden_existing_staff_profil Result result = await handler.HandleAsync( new BootstrapStaffIdentityCommand( + Guid.NewGuid(), Guid.NewGuid(), "member-100", "Ada Operator", @@ -1520,6 +1717,7 @@ public async Task Identity_bootstrap_rejects_source_operation_reuse_for_another_ Result result = await handler.HandleAsync( new BootstrapStaffIdentityCommand( operationId, + Guid.NewGuid(), "member-different", "Different Owner", "different@example.test", @@ -1999,6 +2197,53 @@ public async Task Resume_maps_retryable_workspace_coordination_to_a_stable_staff Assert.Equal(StaffStatus.Active, policy.Context.TargetStatus); } + [Fact] + public async Task Unresolved_workspace_anchor_blocks_resume_before_workspace_preparation() + { + StaffMember member = CreateMember("member-100"); + Assert.True(member.Suspend( + member.Version, + "user:owner", + "Leave", + Guid.NewGuid(), + TestClock.Now).IsSuccess); + long suspendedVersion = member.Version; + int eventCount = member.DomainEvents.Count; + RecordingLifecyclePolicy policy = new( + StaffLifecyclePolicyDecision.Allowed); + RecordingMemberMutationOperations operations = new(); + using ServiceProvider provider = CreateProvider( + new FakeStaffMemberRepository(member), + new FakePropertyProjectionRepository(), + lifecyclePolicy: policy, + identityAnchorResolutions: + new StubStaffIdentityProvisioningAnchorResolutionRepository( + hasUnresolved: true), + memberMutationOperations: operations); + var handler = provider.GetRequiredService>(); + + Result result = + await handler.HandleAsync( + new ResumeStaffMemberCommand( + Guid.NewGuid(), + member.Id, + "Returned", + member.Version, + "user:owner"), + CancellationToken.None); + + Assert.Equal( + StaffApplicationErrors.IdentityAnchorResolutionRequired, + result.Error); + Assert.Equal(StaffMemberState.Suspended, member.Status); + Assert.Equal(suspendedVersion, member.Version); + Assert.Equal(eventCount, member.DomainEvents.Count); + Assert.Empty(policy.Contexts); + Assert.Empty(operations.Records); + } + [Fact] public async Task Resume_replays_without_repeating_workspace_preparation() { @@ -2197,6 +2442,9 @@ private static ServiceProvider CreateProvider( IStaffLifecyclePolicy? lifecyclePolicy = null, IStaffOperationLock? operationLock = null, IStaffCreationOperationLock? creationLock = null, + IStaffIdentityProvisioningAnchorRepository? identityAnchors = null, + IStaffIdentityProvisioningAnchorResolutionRepository? + identityAnchorResolutions = null, IStaffMemberMutationOperationRepository? memberMutationOperations = null, IStaffOnboardingProvisioningOperationRepository? onboardingOperations = null) @@ -2210,6 +2458,15 @@ private static ServiceProvider CreateProvider( services.AddSingleton(operationLock ?? new NoopStaffOperationLock()); services.AddSingleton( creationLock ?? new NoopStaffCreationOperationLock()); + IStaffIdentityProvisioningAnchorRepository anchorRepository = + identityAnchors ?? new RecordingIdentityProvisioningAnchors(); + services.AddSingleton(anchorRepository); + services.AddSingleton( + new StubStaffIdentityProvisioningAnchorWriter(anchorRepository)); + services.AddSingleton< + IStaffIdentityProvisioningAnchorResolutionRepository>( + identityAnchorResolutions ?? + new StubStaffIdentityProvisioningAnchorResolutionRepository()); services.AddSingleton( memberMutationOperations ?? new RecordingMemberMutationOperations()); services.AddSingleton( @@ -2319,6 +2576,20 @@ public Task AddAsync(StaffMember value, CancellationToken cancellationToken) candidate => candidate.Id == staffMemberId)); } + public Task GetForSafetyTransitionByAuthSubjectAsync( + string authSubjectId, + CancellationToken cancellationToken) + { + this.Calls?.Add("safety-subject-read"); + this.SafetyGetCount++; + string normalized = authSubjectId.Trim(); + return Task.FromResult(this.Candidates().FirstOrDefault( + candidate => string.Equals( + candidate.AuthSubjectId, + normalized, + StringComparison.Ordinal))); + } + public Task GetByAuthSubjectAsync(string authSubjectId, CancellationToken cancellationToken) { @@ -2452,6 +2723,7 @@ private sealed class RecordingOperationLock(Action? onAcquire = null) : IStaffOperationLock { public List<(string TenantId, Guid StaffMemberId)> Acquisitions { get; } = []; + public Action? OnAcquireById { get; set; } public Task GetStaffMemberRevisionAsync( string tenantId, @@ -2465,6 +2737,7 @@ public Task TryAcquireStaffMemberAsync( { this.Acquisitions.Add((tenantId, staffMemberId)); onAcquire?.Invoke(); + this.OnAcquireById?.Invoke(staffMemberId); return Task.FromResult(true); } } @@ -2520,6 +2793,36 @@ public Task DeleteForStaffMemberAsync( } } + private sealed class RecordingIdentityProvisioningAnchors + : IStaffIdentityProvisioningAnchorRepository + { + public List Records { get; } = []; + + public Task GetAsync( + StaffIdentityProvisioningSourceKind sourceKind, + Guid sourceId, + CancellationToken cancellationToken) => Task.FromResult( + this.Records.SingleOrDefault(record => + record.SourceKind == sourceKind && + record.SourceId == sourceId)); + + public Task HasWorkspaceOnboardingAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => Task.FromResult( + this.Records.Any(record => + record.SourceKind == + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding && + record.StaffMemberId == staffMemberId)); + + public Task AddAsync( + StaffIdentityProvisioningAnchorRecord anchor, + CancellationToken cancellationToken) + { + this.Records.Add(anchor); + return Task.CompletedTask; + } + } + private sealed class RecordingOnboardingOperations : IStaffOnboardingProvisioningOperationRepository { diff --git a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/StaffIdentityProvisioningAnchorCutoverTests.cs b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/StaffIdentityProvisioningAnchorCutoverTests.cs new file mode 100644 index 00000000..e60a37a8 --- /dev/null +++ b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/StaffIdentityProvisioningAnchorCutoverTests.cs @@ -0,0 +1,816 @@ +namespace BunkFy.Modules.Staff.Tests; + +using BunkFy.Modules.Staff.Application; +using BunkFy.Modules.Staff.Application.Handlers; +using BunkFy.Modules.Staff.Application.Ports; +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Staff.Domain.Aggregates; +using BunkFy.Modules.Staff.Domain.ValueObjects; +using Gma.Framework.Pagination; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Time; +using Gma.Framework.Scoping; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class StaffIdentityProvisioningAnchorCutoverTests +{ + private static readonly DateTimeOffset Now = + new(2026, 8, 11, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task Inspect_classifies_only_exact_Workspaces_evidence_as_seedable() + { + Guid mappedSource = Guid.NewGuid(); + Guid ambiguousSource = Guid.NewGuid(); + Guid receiptOnlySource = Guid.NewGuid(); + StaffMember mapped = CreateMember(); + StaffMember receiptTarget = CreateMember(); + FakeOperations operations = new(); + operations.Add(Receipt(receiptOnlySource, receiptTarget.Id)); + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator = Create( + [mapped, receiptTarget], + operations: operations); + + Result> result = + await coordinator.InspectAsync( + [ + Workspace(mappedSource, mapped.Id), + Workspace(ambiguousSource, null), + Workspace(receiptOnlySource, null) + ], + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal( + StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromWorkspace, + Find(result.Value, mappedSource).Disposition); + Assert.Equal( + StaffIdentityProvisioningAnchorCutoverDisposition.Ambiguous, + Find(result.Value, ambiguousSource).Disposition); + Assert.Equal( + StaffIdentityProvisioningAnchorCutoverDisposition.Conflict, + Find(result.Value, receiptOnlySource).Disposition); + } + + [Fact] + public async Task Inspect_requires_the_reviewed_owner_map_to_match_the_target_subject() + { + StaffMember member = CreateMember(); + Guid membershipId = Guid.NewGuid(); + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator = Create( + [member]); + + Result> result = + await coordinator.InspectAsync( + [Owner(membershipId, member.Id, member.AuthSubjectId!)], + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal( + StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromReviewedOwnerMap, + Assert.Single(result.Value).Disposition); + } + + [Fact] + public async Task Inspect_requires_exact_Workspaces_subject_and_fails_closed_after_target_erasure() + { + StaffMember live = CreateMember("subject:live"); + StaffMember erased = CreateMember(authSubjectId: null); + Guid mismatchedSource = Guid.NewGuid(); + Guid erasedSource = Guid.NewGuid(); + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator = Create( + [live, erased]); + + Result> result = + await coordinator.InspectAsync( + [ + Workspace( + mismatchedSource, + live.Id, + "subject:different"), + Workspace( + erasedSource, + erased.Id, + "subject:historical") + ], + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.All(result.Value, item => Assert.Equal( + StaffIdentityProvisioningAnchorCutoverDisposition.Conflict, + item.Disposition)); + } + + [Fact] + public async Task Inspect_rejects_a_different_subject_and_requires_explicit_erased_target_review() + { + StaffMember live = CreateMember("subject:live"); + StaffMember erased = CreateMember(authSubjectId: null); + Guid differentSubjectMembershipId = Guid.NewGuid(); + Guid unreviewedErasedMembershipId = Guid.NewGuid(); + Guid reviewedErasedMembershipId = Guid.NewGuid(); + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator = Create( + [live, erased]); + + Result> result = + await coordinator.InspectAsync( + [ + Owner( + differentSubjectMembershipId, + live.Id, + "subject:different"), + Owner( + unreviewedErasedMembershipId, + erased.Id, + "subject:historical"), + Owner( + reviewedErasedMembershipId, + erased.Id, + "subject:historical", + reviewedErasedTarget: true) + ], + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Dictionary + dispositions = result.Value.ToDictionary( + item => item.SourceId, + item => item.Disposition); + Assert.Equal( + StaffIdentityProvisioningAnchorCutoverDisposition.Conflict, + dispositions[differentSubjectMembershipId]); + Assert.Equal( + StaffIdentityProvisioningAnchorCutoverDisposition.Conflict, + dispositions[unreviewedErasedMembershipId]); + Assert.Equal( + StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromReviewedOwnerMap, + dispositions[reviewedErasedMembershipId]); + } + + [Fact] + public async Task Inspect_treats_an_exact_existing_anchor_as_ready_after_target_erasure() + { + StaffMember erased = CreateMember(authSubjectId: null); + Guid membershipId = Guid.NewGuid(); + FakeAnchors anchors = new(); + anchors.Records.Add(new( + "tenant-a", + StaffIdentityProvisioningSourceKind.OrganizationMembership, + membershipId, + erased.Id, + Now)); + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator = Create( + [erased], + anchors); + + Result> result = + await coordinator.InspectAsync( + [new( + StaffIdentityProvisioningAnchorSourceKind + .OrganizationMembership, + membershipId, + StaffMemberId: null, + ExpectedAuthSubjectId: "subject:historical")], + CancellationToken.None); + + Assert.Equal( + StaffIdentityProvisioningAnchorCutoverDisposition.AlreadyAnchored, + Assert.Single(result.Value).Disposition); + } + + [Fact] + public async Task Inspect_rejects_an_existing_owner_anchor_whose_live_target_has_a_different_subject() + { + StaffMember target = CreateMember("subject:other"); + Guid membershipId = Guid.NewGuid(); + FakeAnchors anchors = new(); + anchors.Records.Add(new( + "tenant-a", + StaffIdentityProvisioningSourceKind.OrganizationMembership, + membershipId, + target.Id, + Now)); + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator = Create( + [target], + anchors); + + Result> result = + await coordinator.InspectAsync( + [new( + StaffIdentityProvisioningAnchorSourceKind + .OrganizationMembership, + membershipId, + StaffMemberId: null, + ExpectedAuthSubjectId: "subject:expected")], + CancellationToken.None); + + Assert.Equal( + StaffIdentityProvisioningAnchorCutoverDisposition.Conflict, + Assert.Single(result.Value).Disposition); + } + + [Fact] + public async Task Inspect_reports_missing_targets_and_conflicting_anchors() + { + Guid sourceId = Guid.NewGuid(); + StaffMember expected = CreateMember(); + StaffMember other = CreateMember(); + FakeAnchors anchors = new(); + anchors.Records.Add(new( + "tenant-a", + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + sourceId, + other.Id, + Now)); + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator = Create( + [expected, other], + anchors: anchors); + + Result> conflict = + await coordinator.InspectAsync( + [Workspace(sourceId, expected.Id)], + CancellationToken.None); + Result> missing = + await Create([]).InspectAsync( + [Workspace(Guid.NewGuid(), Guid.NewGuid())], + CancellationToken.None); + + Assert.Equal( + StaffIdentityProvisioningAnchorCutoverDisposition.Conflict, + Assert.Single(conflict.Value).Disposition); + Assert.Equal( + StaffIdentityProvisioningAnchorCutoverDisposition.Conflict, + Assert.Single(missing.Value).Disposition); + } + + [Fact] + public async Task Apply_preflights_the_entire_batch_and_writes_nothing_on_conflict() + { + StaffMember member = CreateMember(); + FakeAnchors anchors = new(); + RecordingLock sourceLock = new(); + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator = Create( + [member], + anchors, + creationLock: sourceLock); + Guid good = Guid.NewGuid(); + Guid bad = Guid.NewGuid(); + + Result result = + await coordinator.ApplyAsync( + "tenant-a", + [Workspace(good, member.Id), Workspace(bad, Guid.NewGuid())], + CancellationToken.None); + + Assert.Equal( + StaffApplicationErrors.IdentityAnchorCutoverBlocked, + result.Error); + Assert.Empty(anchors.Records); + Assert.Equal( + new[] { bad, good }.Order().ToArray(), + sourceLock.SourceIds); + } + + [Fact] + public async Task Apply_rechecks_the_owner_subject_after_acquiring_the_target_lock() + { + StaffMember member = CreateMember("subject:owner-a"); + FakeAnchors anchors = new(); + CallbackOperationLock targetLock = new(staffMemberId => + { + Assert.Equal(member.Id, staffMemberId); + Assert.True(member.Suspend( + member.Version, + "system:concurrent-transition", + "Concurrent identity transition.", + Guid.NewGuid(), + Now.AddMinutes(1)).IsSuccess); + Assert.True(member.SetAuthSubject( + null, + member.Version, + "system:concurrent-transition", + Guid.NewGuid(), + Now.AddMinutes(2)).IsSuccess); + }); + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator = Create( + [member], + anchors, + memberLock: targetLock); + + Result result = + await coordinator.ApplyAsync( + "tenant-a", + [Owner(Guid.NewGuid(), member.Id, "subject:owner-a")], + CancellationToken.None); + + Assert.Equal( + StaffApplicationErrors.IdentityAnchorCutoverBlocked, + result.Error); + Assert.Empty(anchors.Records); + Assert.Equal(1, targetLock.AcquireCount); + } + + [Fact] + public async Task Apply_atomically_adds_both_source_kinds_and_preserves_receipt_time() + { + StaffMember workspaceMember = CreateMember(); + StaffMember ownerMember = CreateMember(); + Guid applicationId = Guid.NewGuid(); + Guid membershipId = Guid.NewGuid(); + DateTimeOffset completedAtUtc = Now.AddDays(-10); + FakeOperations operations = new(); + operations.Add(Receipt( + applicationId, + workspaceMember.Id, + completedAtUtc)); + FakeAnchors anchors = new(); + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator = Create( + [workspaceMember, ownerMember], + anchors, + operations); + + Result result = + await coordinator.ApplyAsync( + "tenant-a", + [ + Workspace(applicationId, workspaceMember.Id), + Owner( + membershipId, + ownerMember.Id, + ownerMember.AuthSubjectId!) + ], + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal(2, result.Value.AppliedCount); + Assert.Equal( + completedAtUtc, + anchors.Records.Single(record => + record.SourceId == applicationId).AnchoredAtUtc); + Assert.Equal( + Now, + anchors.Records.Single(record => + record.SourceId == membershipId).AnchoredAtUtc); + } + + [Fact] + public async Task Apply_is_idempotent_for_an_exact_existing_anchor() + { + StaffMember member = CreateMember(); + Guid sourceId = Guid.NewGuid(); + FakeAnchors anchors = new(); + anchors.Records.Add(new( + "tenant-a", + StaffIdentityProvisioningSourceKind.OrganizationMembership, + sourceId, + member.Id, + Now.AddDays(-1))); + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator = Create( + [member], + anchors); + + Result result = + await coordinator.ApplyAsync( + "tenant-a", + [Owner(sourceId, member.Id, member.AuthSubjectId!)], + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal(0, result.Value.AppliedCount); + Assert.Equal(1, result.Value.AlreadyAnchoredCount); + Assert.Single(anchors.Records); + } + + [Fact] + public async Task Inspect_rejects_duplicates_and_oversized_batches() + { + Guid sourceId = Guid.NewGuid(); + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator = Create( + []); + StaffIdentityProvisioningAnchorCandidate duplicate = + Workspace(sourceId, Guid.NewGuid()); + StaffIdentityProvisioningAnchorCandidate[] oversized = Enumerable + .Range( + 0, + StaffWorkspaceOnboardingAnchorCutoverLimits.MaximumBatchSize + + 1) + .Select(_ => Workspace(Guid.NewGuid(), null)) + .ToArray(); + + Result> duplicates = + await coordinator.InspectAsync( + [duplicate, duplicate], + CancellationToken.None); + Result> tooMany = + await coordinator.InspectAsync( + oversized, + CancellationToken.None); + + Assert.Equal( + StaffApplicationErrors.IdentityAnchorCutoverRequestInvalid, + duplicates.Error); + Assert.Equal( + StaffApplicationErrors.IdentityAnchorCutoverRequestInvalid, + tooMany.Error); + } + + [Fact] + public async Task Apply_rejects_an_invalid_subject_precondition_before_source_locks() + { + StaffMember member = CreateMember(); + RecordingLock sourceLock = new(); + FakeAnchors anchors = new(); + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator = Create( + [member], + anchors, + creationLock: sourceLock); + + Result result = + await coordinator.ApplyAsync( + "tenant-a", + [Owner( + Guid.NewGuid(), + member.Id, + new string('x', StaffAuthSubject.MaxLength + 1))], + CancellationToken.None); + + Assert.Equal( + StaffApplicationErrors.IdentityAnchorCutoverRequestInvalid, + result.Error); + Assert.Empty(sourceLock.SourceIds); + Assert.Empty(anchors.Records); + } + + [Fact] + public async Task Inspect_uses_three_bounded_bulk_reads_at_the_maximum_batch() + { + StaffMember member = CreateMember(); + FakeAnchors anchors = new(); + FakeOperations operations = new(); + FakeMembers members = new([member]); + StaffWorkspaceOnboardingAnchorCutoverCoordinator coordinator = new( + anchors, + new RepositoryAnchorWriter(anchors), + operations, + members, + new RecordingLock(), + new StaffMemberMutationCoordinator( + members, + new NoopStaffOperationLock(), + new TestScopeContext()), + new TestClock()); + StaffIdentityProvisioningAnchorCandidate[] candidates = Enumerable + .Range( + 0, + StaffWorkspaceOnboardingAnchorCutoverLimits.MaximumBatchSize) + .Select(_ => Workspace(Guid.NewGuid(), member.Id)) + .ToArray(); + + Result> result = + await coordinator.InspectAsync( + candidates, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal(candidates.Length, result.Value.Count); + Assert.Equal(1, anchors.ListCount); + Assert.Equal(1, operations.ListCount); + Assert.Equal(1, members.SafetyEvidenceListCount); + } + + private static StaffWorkspaceOnboardingAnchorCutoverCoordinator Create( + IReadOnlyList members, + FakeAnchors? anchors = null, + FakeOperations? operations = null, + RecordingLock? creationLock = null, + IStaffOperationLock? memberLock = null) + { + FakeMembers memberRepository = new(members); + FakeAnchors anchorRepository = anchors ?? new(); + return new( + anchorRepository, + new RepositoryAnchorWriter(anchorRepository), + operations ?? new(), + memberRepository, + creationLock ?? new(), + new StaffMemberMutationCoordinator( + memberRepository, + memberLock ?? new NoopStaffOperationLock(), + new TestScopeContext()), + new TestClock()); + } + + private static StaffIdentityProvisioningAnchorCandidate Workspace( + Guid sourceId, + Guid? staffMemberId, + string expectedAuthSubjectId = "subject:staff") => + new( + StaffIdentityProvisioningAnchorSourceKind.WorkspaceOnboarding, + sourceId, + staffMemberId, + expectedAuthSubjectId); + + private static StaffIdentityProvisioningAnchorCandidate Owner( + Guid sourceId, + Guid staffMemberId, + string expectedAuthSubjectId, + bool reviewedErasedTarget = false) => + new( + StaffIdentityProvisioningAnchorSourceKind.OrganizationMembership, + sourceId, + staffMemberId, + expectedAuthSubjectId, + reviewedErasedTarget); + + private static StaffIdentityProvisioningAnchorCandidateInspection Find( + IReadOnlyList items, + Guid sourceId) => items.Single(item => item.SourceId == sourceId); + + private static StaffMemberMutationOperationRecord Receipt( + Guid sourceId, + Guid staffMemberId, + DateTimeOffset? completedAtUtc = null) => + new( + sourceId, + "tenant-a", + staffMemberId, + StaffMemberMutationKind.OnboardingProvision, + 1, + new string('a', 64), + StaffStatus.Active, + 1, + completedAtUtc ?? Now.AddDays(-1)); + + private static StaffMember CreateMember( + string? authSubjectId = "subject:staff") => StaffMember.Create( + Guid.NewGuid(), + "tenant-a", + "Staff member", + null, + null, + null, + null, + null, + null, + authSubjectId, + "system:cutover", + Guid.NewGuid(), + Now).Value; + + private sealed class FakeAnchors : + IStaffIdentityProvisioningAnchorRepository + { + public List Records { get; } = + []; + public int ListCount { get; private set; } + + public Task> + ListAsync( + IReadOnlyList sources, + CancellationToken cancellationToken) + { + this.ListCount++; + HashSet<(StaffIdentityProvisioningSourceKind, Guid)> keys = sources + .Select(source => (source.SourceKind, source.SourceId)) + .ToHashSet(); + return Task.FromResult>(this.Records + .Where(record => keys.Contains( + (record.SourceKind, record.SourceId))) + .ToArray()); + } + + public Task GetAsync( + StaffIdentityProvisioningSourceKind sourceKind, + Guid sourceId, + CancellationToken cancellationToken) => + Task.FromResult(this.Records.SingleOrDefault(record => + record.SourceKind == sourceKind && + record.SourceId == sourceId)); + + public Task HasWorkspaceOnboardingAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => Task.FromResult( + this.Records.Any(record => + record.SourceKind == + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding && + record.StaffMemberId == staffMemberId)); + + public Task AddAsync( + StaffIdentityProvisioningAnchorRecord anchor, + CancellationToken cancellationToken) + { + this.Records.Add(anchor); + return Task.CompletedTask; + } + } + + private sealed class RepositoryAnchorWriter( + IStaffIdentityProvisioningAnchorRepository anchors) + : IStaffIdentityProvisioningAnchorWriter + { + public async Task AddAsync( + StaffIdentityProvisioningAnchorRecord anchor, + CancellationToken cancellationToken) + { + await anchors.AddAsync(anchor, cancellationToken); + return anchor; + } + } + + private sealed class FakeOperations : + IStaffOnboardingProvisioningOperationRepository + { + private readonly Dictionary + records = []; + public int ListCount { get; private set; } + + public Task> + ListAsync( + IReadOnlyList operationIds, + CancellationToken cancellationToken) + { + this.ListCount++; + return Task.FromResult>(operationIds + .Where(this.records.ContainsKey) + .Select(operationId => this.records[operationId]) + .ToArray()); + } + + public Task GetAsync( + Guid operationId, + CancellationToken cancellationToken) => + Task.FromResult(this.records.GetValueOrDefault(operationId)); + + public Task AddAsync( + StaffMemberMutationOperationRecord operation, + CancellationToken cancellationToken) + { + this.Add(operation); + return Task.CompletedTask; + } + + public void Add(StaffMemberMutationOperationRecord operation) => + this.records.Add(operation.OperationId, operation); + } + + private sealed class RecordingLock : IStaffCreationOperationLock + { + public List SourceIds { get; } = []; + + public Task AcquireAsync( + string tenantId, + Guid operationId, + CancellationToken cancellationToken) + { + this.SourceIds.Add(operationId); + return Task.CompletedTask; + } + } + + private sealed class CallbackOperationLock(Action onAcquire) + : IStaffOperationLock + { + public int AcquireCount { get; private set; } + + public Task GetStaffMemberRevisionAsync( + string tenantId, + Guid staffMemberId, + CancellationToken cancellationToken) => + Task.FromResult(1); + + public Task TryAcquireStaffMemberAsync( + string tenantId, + Guid staffMemberId, + CancellationToken cancellationToken) + { + this.AcquireCount++; + onAcquire(staffMemberId); + return Task.FromResult(true); + } + } + + private sealed class TestClock : ISystemClock + { + public DateTimeOffset UtcNow => Now; + } + + private sealed class TestScopeContext : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => "tenant-a"; + } + + private sealed class FakeMembers(IReadOnlyList members) + : IStaffMemberRepository + { + public int SafetyEvidenceListCount { get; private set; } + + public Task> + ListSafetyEvidenceAsync( + IReadOnlyList staffMemberIds, + CancellationToken cancellationToken) + { + this.SafetyEvidenceListCount++; + HashSet ids = staffMemberIds.ToHashSet(); + return Task.FromResult>( + members.Where(member => ids.Contains(member.Id)) + .Select(member => new StaffMemberSafetyEvidence( + member.Id, + member.AuthSubjectId, + member.Status)) + .ToArray()); + } + + public Task GetForSafetyTransitionAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + Task.FromResult(members.SingleOrDefault(member => + member.Id == staffMemberId)); + + public Task AddAsync( + StaffMember member, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetForDataRightsAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetForSafetyTransitionByAuthSubjectAsync( + string authSubjectId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetByAuthSubjectAsync( + string authSubjectId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetDirectoryAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetDirectoryAtPropertyAsync( + Guid propertyId, + Guid staffMemberId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task ListDirectoryAsync( + string? search, + StaffStatus? status, + PageRequest pageRequest, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task + ListDirectoryAtPropertyAsync( + Guid propertyId, + string? search, + StaffStatus? status, + PageRequest pageRequest, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task EmployeeNumberExistsAsync( + string employeeNumber, + Guid? exceptStaffMemberId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task AuthSubjectExistsAsync( + string authSubjectId, + Guid? exceptStaffMemberId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } +} diff --git a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/StaffProcessingRestrictionCommandHandlerTests.cs b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/StaffProcessingRestrictionCommandHandlerTests.cs index 158043a4..b81600e9 100644 --- a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/StaffProcessingRestrictionCommandHandlerTests.cs +++ b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/StaffProcessingRestrictionCommandHandlerTests.cs @@ -418,6 +418,11 @@ public Task AddAsync( CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetForSafetyTransitionByAuthSubjectAsync( + string authSubjectId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + public Task GetByAuthSubjectAsync( string authSubjectId, CancellationToken cancellationToken) => diff --git a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/StaffWorkspaceOnboardingIdentityAnchorLifecycleTests.cs b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/StaffWorkspaceOnboardingIdentityAnchorLifecycleTests.cs new file mode 100644 index 00000000..3972215e --- /dev/null +++ b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Application/StaffWorkspaceOnboardingIdentityAnchorLifecycleTests.cs @@ -0,0 +1,734 @@ +namespace BunkFy.Modules.Staff.Tests; + +using BunkFy.Modules.Staff.Application; +using BunkFy.Modules.Staff.Application.Handlers; +using BunkFy.Modules.Staff.Application.Ports; +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Staff.Domain.Aggregates; +using Gma.Framework.Messaging; +using Gma.Framework.Pagination; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class StaffWorkspaceOnboardingIdentityAnchorLifecycleTests +{ + private static readonly DateTimeOffset AnchoredAtUtc = + new(2026, 8, 11, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task Read_is_ordered_and_never_echoes_expected_subjects() + { + Guid exactId = Guid.NewGuid(); + Guid mismatchId = Guid.NewGuid(); + Guid missingId = Guid.NewGuid(); + Guid absentId = Guid.NewGuid(); + Guid exactMemberId = Guid.NewGuid(); + Guid mismatchMemberId = Guid.NewGuid(); + Guid missingMemberId = Guid.NewGuid(); + FakeAnchors anchors = new( + Anchor(exactId, exactMemberId), + Anchor(mismatchId, mismatchMemberId), + Anchor(missingId, missingMemberId)); + FakeResolutions resolutions = new(); + FakeMembers members = new( + new(exactMemberId, "subject:exact", StaffMemberState.Active), + new(mismatchMemberId, "subject:other", StaffMemberState.Suspended), + new(missingMemberId, null, StaffMemberState.Anonymised)); + StaffWorkspaceOnboardingIdentityAnchorLifecycleCoordinator coordinator = + new(anchors, resolutions, members, new RecordingCreationLock()); + + Result> result = + await coordinator.ReadAsync( + [ + new(mismatchId, "subject:expected"), + new(absentId, "subject:absent"), + new(exactId, " subject:exact "), + new(missingId, "subject:erased") + ], + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal( + [mismatchId, absentId, exactId, missingId], + result.Value.Select(outcome => outcome.ApplicationId)); + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Mismatch, + result.Value[0].SubjectMatch); + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Absent, + result.Value[1].Status); + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact, + result.Value[2].SubjectMatch); + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Missing, + result.Value[3].SubjectMatch); + string serialized = System.Text.Json.JsonSerializer.Serialize( + result.Value); + Assert.DoesNotContain("subject:", serialized, StringComparison.Ordinal); + } + + [Fact] + public async Task Read_reports_resolution_disposition_and_application_version() + { + Guid applicationId = Guid.NewGuid(); + Guid staffMemberId = Guid.NewGuid(); + Guid resolutionEventId = NewResolutionEventId(applicationId); + FakeAnchors anchors = new(Anchor( + applicationId, + staffMemberId, + resolutionEventId)); + FakeResolutions resolutions = new( + Resolution( + applicationId, + staffMemberId, + resolutionEventId, + workspaceApplicationVersion: 17)); + StaffWorkspaceOnboardingIdentityAnchorLifecycleCoordinator coordinator = + new( + anchors, + resolutions, + new FakeMembers(new StaffMemberSafetyEvidence( + staffMemberId, + "subject:staff", + StaffMemberState.Departed)), + new RecordingCreationLock()); + + Result> result = + await coordinator.ReadAsync( + [new(applicationId, "subject:staff")], + CancellationToken.None); + + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome = + Assert.Single(result.Value); + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved, + outcome.Status); + Assert.Equal(17, outcome.WorkspaceApplicationVersion); + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + outcome.ResolutionDisposition); + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Departed, + outcome.TargetLifecycle); + Assert.Equal(resolutionEventId, outcome.ResolutionEventId); + } + + [Fact] + public async Task Read_reports_a_resolution_event_mismatching_its_anchor_as_corrupt() + { + Guid applicationId = Guid.NewGuid(); + Guid staffMemberId = Guid.NewGuid(); + Guid anchorResolutionEventId = NewResolutionEventId(applicationId); + Guid mismatchedResolutionEventId = NewDistinctId( + applicationId, + anchorResolutionEventId); + FakeAnchors anchors = new(Anchor( + applicationId, + staffMemberId, + anchorResolutionEventId)); + FakeResolutions resolutions = new( + Resolution( + applicationId, + staffMemberId, + mismatchedResolutionEventId)); + StaffWorkspaceOnboardingIdentityAnchorLifecycleCoordinator coordinator = + new( + anchors, + resolutions, + new FakeMembers(new StaffMemberSafetyEvidence( + staffMemberId, + "subject:staff", + StaffMemberState.Active)), + new RecordingCreationLock()); + + Result> result = + await coordinator.ReadAsync( + [new(applicationId, "subject:staff")], + CancellationToken.None); + + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Corrupt, + Assert.Single(result.Value).Status); + } + + [Fact] + public async Task Record_cannot_manufacture_or_retarget_an_anchor() + { + Guid applicationId = Guid.NewGuid(); + Guid staffMemberId = Guid.NewGuid(); + Guid resolutionEventId = NewResolutionEventId(applicationId); + FakeAnchors anchors = new(); + FakeResolutions resolutions = new(); + RecordingCreationLock creationLock = new(); + StaffWorkspaceOnboardingIdentityAnchorLifecycleCoordinator coordinator = + new(anchors, resolutions, new FakeMembers(), creationLock); + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest request = + Request(applicationId, staffMemberId, resolutionEventId); + + Result absent = + await coordinator.RecordAsync( + "tenant-a", + request, + CancellationToken.None); + anchors.Records.Add(Anchor( + applicationId, + Guid.NewGuid(), + resolutionEventId)); + Result + retargeted = await coordinator.RecordAsync( + "tenant-a", + request, + CancellationToken.None); + + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus.AnchorAbsent, + absent.Value); + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus.Conflict, + retargeted.Value); + Assert.Empty(resolutions.Records); + Assert.Equal([applicationId, applicationId], creationLock.SourceIds); + } + + [Fact] + public async Task Record_is_microsecond_exact_idempotent_and_causally_timed() + { + Guid applicationId = Guid.NewGuid(); + Guid staffMemberId = Guid.NewGuid(); + Guid resolutionEventId = NewResolutionEventId(applicationId); + FakeAnchors anchors = new(Anchor( + applicationId, + staffMemberId, + resolutionEventId)); + FakeResolutions resolutions = new(); + StaffWorkspaceOnboardingIdentityAnchorLifecycleCoordinator coordinator = + new( + anchors, + resolutions, + new FakeMembers(), + new RecordingCreationLock()); + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest request = + Request( + applicationId, + staffMemberId, + resolutionEventId, + AnchoredAtUtc.AddTicks(-7)); + + Result first = + await coordinator.RecordAsync( + "tenant-a", + request, + CancellationToken.None); + Result replay = + await coordinator.RecordAsync( + "tenant-a", + request, + CancellationToken.None); + Result + divergentTime = + await coordinator.RecordAsync( + "tenant-a", + request with + { + ResolvedAtUtc = AnchoredAtUtc.AddMinutes(1) + }, + CancellationToken.None); + + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus.Recorded, + first.Value); + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus + .AlreadyRecorded, + replay.Value); + Assert.Equal( + AnchoredAtUtc, + Assert.Single(resolutions.Records).ResolvedAtUtc); + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus.Conflict, + divergentTime.Value); + } + + [Fact] + public async Task Read_rejects_invalid_or_duplicate_coordinates_before_storage() + { + FakeAnchors anchors = new(); + FakeResolutions resolutions = new(); + StaffWorkspaceOnboardingIdentityAnchorLifecycleCoordinator coordinator = + new( + anchors, + resolutions, + new FakeMembers(), + new RecordingCreationLock()); + Guid applicationId = Guid.NewGuid(); + + Result> result = + await coordinator.ReadAsync( + [ + new(applicationId, "subject:staff"), + new(applicationId, "subject:other") + ], + CancellationToken.None); + + Assert.Equal( + StaffApplicationErrors.IdentityAnchorLifecycleRequestInvalid, + result.Error); + Assert.Equal(0, anchors.ListCount); + Assert.Equal(0, resolutions.ListCount); + } + + [Fact] + public async Task Record_requires_the_resolution_event_id_owned_by_the_anchor() + { + Guid applicationId = Guid.NewGuid(); + Guid staffMemberId = Guid.NewGuid(); + Guid anchorResolutionEventId = NewResolutionEventId(applicationId); + Guid mismatchedResolutionEventId = NewDistinctId( + applicationId, + anchorResolutionEventId); + FakeResolutions resolutions = new(); + RecordingCreationLock creationLock = new(); + StaffWorkspaceOnboardingIdentityAnchorLifecycleCoordinator coordinator = + new( + new FakeAnchors(Anchor( + applicationId, + staffMemberId, + anchorResolutionEventId)), + resolutions, + new FakeMembers(), + creationLock); + + Result + reusedSource = + await coordinator.RecordAsync( + "tenant-a", + Request( + applicationId, + staffMemberId, + applicationId), + CancellationToken.None); + Result + mismatchedEvent = + await coordinator.RecordAsync( + "tenant-a", + Request( + applicationId, + staffMemberId, + mismatchedResolutionEventId), + CancellationToken.None); + + Assert.Equal( + StaffApplicationErrors.IdentityAnchorLifecycleRequestInvalid, + reusedSource.Error); + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus.Conflict, + mismatchedEvent.Value); + Assert.Equal([applicationId], creationLock.SourceIds); + Assert.Empty(resolutions.Records); + } + + [Fact] + public async Task Writer_persists_distinct_random_coordinates_when_historical_xor_domains_collide() + { + Guid firstApplicationId = + Guid.Parse("10000000-0000-0000-0000-000000000001"); + Guid historicalCollision = HistoricalXor( + firstApplicationId, + HistoricalContinuationMask); + Guid secondApplicationId = HistoricalXor( + historicalCollision, + HistoricalResolutionMask); + Assert.Equal( + historicalCollision, + HistoricalXor( + secondApplicationId, + HistoricalResolutionMask)); + + Guid firstResolutionEventId = NewDistinctId( + firstApplicationId, + secondApplicationId); + Guid secondResolutionEventId = NewDistinctId( + secondApplicationId, + firstApplicationId, + firstResolutionEventId); + FakeAnchors anchors = new(); + RecordingOutbox outbox = new(); + SequenceIdGenerator ids = new( + firstResolutionEventId, + secondResolutionEventId); + StaffIdentityProvisioningAnchorWriter writer = new( + anchors, + new RecordingOutboxRegistry(outbox), + ids); + + StaffIdentityProvisioningAnchorRecord first = + await writer.AddAsync( + UnownedAnchor(firstApplicationId, Guid.NewGuid()), + CancellationToken.None); + StaffIdentityProvisioningAnchorRecord second = + await writer.AddAsync( + UnownedAnchor(secondApplicationId, Guid.NewGuid()), + CancellationToken.None); + + Assert.Equal(firstResolutionEventId, first.ResolutionEventId); + Assert.Equal(secondResolutionEventId, second.ResolutionEventId); + Assert.NotEqual(first.ResolutionEventId, second.ResolutionEventId); + Assert.Equal( + [firstResolutionEventId, secondResolutionEventId], + anchors.Records.Select(anchor => + anchor.ResolutionEventId!.Value)); + Assert.Equal( + [firstResolutionEventId, secondResolutionEventId], + outbox.Events + .Cast() + .Select(integrationEvent => + integrationEvent.ResolutionEventId)); + Assert.Equal(2, ids.CallCount); + } + + [Fact] + public async Task Writer_rejects_a_caller_supplied_resolution_coordinate() + { + Guid applicationId = Guid.NewGuid(); + FakeAnchors anchors = new(); + RecordingOutbox outbox = new(); + SequenceIdGenerator ids = new(NewResolutionEventId(applicationId)); + StaffIdentityProvisioningAnchorWriter writer = new( + anchors, + new RecordingOutboxRegistry(outbox), + ids); + + await Assert.ThrowsAsync(() => + writer.AddAsync( + Anchor( + applicationId, + Guid.NewGuid(), + NewResolutionEventId(applicationId)), + CancellationToken.None)); + + Assert.Empty(anchors.Records); + Assert.Empty(outbox.Events); + Assert.Equal(0, ids.CallCount); + } + + private static StaffIdentityProvisioningAnchorRecord Anchor( + Guid applicationId, + Guid staffMemberId, + Guid? resolutionEventId = null) => + new( + "tenant-a", + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + applicationId, + staffMemberId, + AnchoredAtUtc, + resolutionEventId ?? NewResolutionEventId(applicationId)); + + private static StaffIdentityProvisioningAnchorRecord UnownedAnchor( + Guid applicationId, + Guid staffMemberId) => + new( + "tenant-a", + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + applicationId, + staffMemberId, + AnchoredAtUtc); + + private static StaffIdentityProvisioningAnchorResolutionRecord Resolution( + Guid applicationId, + Guid staffMemberId, + Guid resolutionEventId, + long workspaceApplicationVersion = 9) => + new( + "tenant-a", + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + applicationId, + staffMemberId, + workspaceApplicationVersion, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + resolutionEventId, + AnchoredAtUtc.AddMinutes(1)); + + private static StaffWorkspaceOnboardingIdentityAnchorResolutionRequest + Request( + Guid applicationId, + Guid staffMemberId, + Guid resolutionEventId, + DateTimeOffset? resolvedAtUtc = null) => + new( + resolutionEventId, + applicationId, + staffMemberId, + 9, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + resolvedAtUtc ?? AnchoredAtUtc.AddMinutes(1)); + + private static Guid NewResolutionEventId(Guid applicationId) => + NewDistinctId(applicationId); + + private static Guid NewDistinctId(params Guid[] excluded) + { + Guid candidate; + do + { + candidate = Guid.NewGuid(); + } + while (candidate == Guid.Empty || excluded.Contains(candidate)); + + return candidate; + } + + private static Guid HistoricalXor(Guid sourceId, Guid domainMask) + { + byte[] sourceBytes = sourceId.ToByteArray(bigEndian: true); + byte[] maskBytes = domainMask.ToByteArray(bigEndian: true); + for (int index = 0; index < sourceBytes.Length; index++) + { + sourceBytes[index] ^= maskBytes[index]; + } + + return new Guid(sourceBytes, bigEndian: true); + } + + private sealed class FakeAnchors( + params StaffIdentityProvisioningAnchorRecord[] records) + : IStaffIdentityProvisioningAnchorRepository + { + public List Records { get; } = + [.. records]; + public int ListCount { get; private set; } + + public Task> + ListAsync( + IReadOnlyList sources, + CancellationToken cancellationToken) + { + this.ListCount++; + HashSet<(StaffIdentityProvisioningSourceKind, Guid)> keys = + sources.Select(source => + (source.SourceKind, source.SourceId)).ToHashSet(); + return Task.FromResult>(this.Records + .Where(record => keys.Contains( + (record.SourceKind, record.SourceId))) + .ToArray()); + } + + public Task GetAsync( + StaffIdentityProvisioningSourceKind sourceKind, + Guid sourceId, + CancellationToken cancellationToken) => + Task.FromResult(this.Records.SingleOrDefault(record => + record.SourceKind == sourceKind && + record.SourceId == sourceId)); + + public Task HasWorkspaceOnboardingAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => Task.FromResult( + this.Records.Any(record => + record.SourceKind == + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding && + record.StaffMemberId == staffMemberId)); + + public Task AddAsync( + StaffIdentityProvisioningAnchorRecord anchor, + CancellationToken cancellationToken) + { + this.Records.Add(anchor); + return Task.CompletedTask; + } + } + + private sealed class FakeResolutions( + params StaffIdentityProvisioningAnchorResolutionRecord[] records) + : IStaffIdentityProvisioningAnchorResolutionRepository + { + public List Records + { + get; + } = [.. records]; + public int ListCount { get; private set; } + + public Task> ListAsync( + IReadOnlyList sourceIds, + CancellationToken cancellationToken) + { + this.ListCount++; + return Task.FromResult>(this.Records + .Where(record => sourceIds.Contains(record.SourceId)) + .ToArray()); + } + + public Task GetAsync( + Guid sourceId, + CancellationToken cancellationToken) => + Task.FromResult(this.Records.SingleOrDefault(record => + record.SourceId == sourceId)); + + public Task HasUnresolvedWorkspaceOnboardingAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task AddAsync( + StaffIdentityProvisioningAnchorResolutionRecord resolution, + CancellationToken cancellationToken) + { + this.Records.Add(resolution); + return Task.CompletedTask; + } + } + + private sealed class RecordingCreationLock : IStaffCreationOperationLock + { + public List SourceIds { get; } = []; + + public Task AcquireAsync( + string tenantId, + Guid operationId, + CancellationToken cancellationToken) + { + this.SourceIds.Add(operationId); + return Task.CompletedTask; + } + } + + private sealed class RecordingOutbox : IOutboxWriter + { + public string ModuleName => StaffModuleMetadata.Name; + public List Events { get; } = []; + + public Task EnqueueAsync( + TEvent integrationEvent, + CancellationToken cancellationToken) + where TEvent : IIntegrationEvent + { + this.Events.Add(integrationEvent); + return Task.CompletedTask; + } + } + + private sealed class RecordingOutboxRegistry(RecordingOutbox outbox) + : IOutboxWriterRegistry + { + public IOutboxWriter GetRequired(string moduleName) + { + Assert.Equal(StaffModuleMetadata.Name, moduleName); + return outbox; + } + } + + private sealed class SequenceIdGenerator(params Guid[] values) + : IIdGenerator + { + private readonly Queue values = new(values); + + public int CallCount { get; private set; } + + public Guid NewId() + { + this.CallCount++; + return this.values.Count > 0 + ? this.values.Dequeue() + : throw new InvalidOperationException( + "No test identity coordinate remains."); + } + } + + private sealed class FakeMembers(params StaffMemberSafetyEvidence[] records) + : IStaffMemberRepository + { + public Task> + ListSafetyEvidenceAsync( + IReadOnlyList staffMemberIds, + CancellationToken cancellationToken) => + Task.FromResult>( + records.Where(record => + staffMemberIds.Contains(record.StaffMemberId)).ToArray()); + + public Task AddAsync( + StaffMember member, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetForDataRightsAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetForSafetyTransitionAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetForSafetyTransitionByAuthSubjectAsync( + string authSubjectId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetByAuthSubjectAsync( + string authSubjectId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetDirectoryAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetDirectoryAtPropertyAsync( + Guid propertyId, + Guid staffMemberId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task ListDirectoryAsync( + string? search, + StaffStatus? status, + PageRequest pageRequest, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task + ListDirectoryAtPropertyAsync( + Guid propertyId, + string? search, + StaffStatus? status, + PageRequest pageRequest, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task EmployeeNumberExistsAsync( + string employeeNumber, + Guid? exceptStaffMemberId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task AuthSubjectExistsAsync( + string authSubjectId, + Guid? exceptStaffMemberId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + + private static readonly Guid HistoricalContinuationMask = + Guid.Parse("6c8c3277-a4b4-4cb0-b052-c51655d83cf7"); + private static readonly Guid HistoricalResolutionMask = + Guid.Parse("d4e73767-4c8d-4e82-b2e9-6b51f48ed6a1"); +} diff --git a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Contracts/StaffContractTests.cs b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Contracts/StaffContractTests.cs index 7e483b33..feafd63f 100644 --- a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Contracts/StaffContractTests.cs +++ b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Contracts/StaffContractTests.cs @@ -39,12 +39,18 @@ public void Descriptor_exposes_scoped_permissions_property_subscriptions_and_reb permission.Code == StaffAdminPermissionCodes.DataHoldsManage); Assert.Equal(3, StaffModuleMetadata.Descriptor.GetSubscriptions().Count); - Assert.Equal(8, StaffModuleMetadata.Descriptor.GetPublishedEvents().Count); + Assert.Equal(9, StaffModuleMetadata.Descriptor.GetPublishedEvents().Count); Assert.Contains( StaffModuleMetadata.Descriptor.GetPublishedEvents(), published => published.EventType == DataRightsTenantCorrectionAppliedIntegrationEvent.EventType); + Assert.Contains( + StaffModuleMetadata.Descriptor.GetPublishedEvents(), + published => + published.EventType == + StaffIdentityProvisioningAnchorCreatedIntegrationEvent + .EventType); Assert.Single(StaffModuleMetadata.Descriptor.GetTasks()); Assert.Single(StaffModuleMetadata.Descriptor.GetCompositionProfiles()); Assert.Equal( @@ -63,6 +69,7 @@ public void Integration_events_are_pii_free_and_subjects_are_stable() typeof(StaffAuthSubjectChangedIntegrationEvent), typeof(StaffPropertyAssignmentChangedIntegrationEvent), typeof(StaffProcessingRestrictionChangedIntegrationEvent), + typeof(StaffIdentityProvisioningAnchorCreatedIntegrationEvent), typeof(DataRightsTenantCorrectionAppliedIntegrationEvent)]; Assert.All(eventTypes, eventType => Assert.DoesNotContain(eventType.GetProperties(), property => forbidden.Contains(property.Name, StringComparer.Ordinal))); @@ -73,4 +80,31 @@ public void Integration_events_are_pii_free_and_subjects_are_stable() Assert.EndsWith(".staff.property-assignment-changed.v1", StaffIntegrationSubjects.CreatePropertyAssignmentChanged(), StringComparison.Ordinal); } + + [Fact] + public void Identity_anchor_created_event_is_bound_to_its_application_coordinate() + { + Guid applicationId = Guid.NewGuid(); + Guid resolutionEventId = Guid.NewGuid(); + + StaffIdentityProvisioningAnchorCreatedIntegrationEvent integrationEvent = + new( + applicationId, + Guid.NewGuid().ToString("D"), + DateTimeOffset.UtcNow, + applicationId, + Guid.NewGuid(), + resolutionEventId); + + Assert.Equal(applicationId, integrationEvent.EventId); + Assert.Equal(resolutionEventId, integrationEvent.ResolutionEventId); + Assert.Throws(() => + new StaffIdentityProvisioningAnchorCreatedIntegrationEvent( + Guid.NewGuid(), + integrationEvent.ScopeId, + integrationEvent.OccurredAtUtc, + applicationId, + integrationEvent.StaffMemberId, + resolutionEventId)); + } } 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..75cb8a5b 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,11 +1,13 @@ 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; using BunkFy.Modules.Staff.Api.Requests; using BunkFy.Modules.Staff.Application.Commands; +using BunkFy.Modules.Staff.Application.Ports; using BunkFy.Modules.Staff.Application.Queries; using BunkFy.Modules.Staff.Contracts; using BunkFy.Modules.Staff.Domain.Aggregates; @@ -81,7 +83,35 @@ public sealed class StaffPersonalDataCatalogTests StringComparer.Ordinal), [typeof(ReleaseStaffDataHoldRequest)] = new( [nameof(ReleaseStaffDataHoldRequest.Confirmed)], - StringComparer.Ordinal) + StringComparer.Ordinal), + [typeof(StaffIdentityProvisioningAnchorApplySummary)] = new( + [ + nameof(StaffIdentityProvisioningAnchorApplySummary + .AppliedCount), + nameof(StaffIdentityProvisioningAnchorApplySummary + .AlreadyAnchoredCount) + ], + StringComparer.Ordinal), + [typeof(StaffIdentityProvisioningAnchorInspection)] = new( + [ + nameof(StaffIdentityProvisioningAnchorInspection.IsSuccess), + nameof(StaffIdentityProvisioningAnchorInspection.ErrorCode) + ], + StringComparer.Ordinal), + [typeof(StaffIdentityProvisioningAnchorApplyResult)] = new( + [ + nameof(StaffIdentityProvisioningAnchorApplyResult.IsSuccess), + nameof(StaffIdentityProvisioningAnchorApplyResult.AppliedCount), + nameof(StaffIdentityProvisioningAnchorApplyResult + .AlreadyAnchoredCount), + nameof(StaffIdentityProvisioningAnchorApplyResult.ErrorCode) + ], + StringComparer.Ordinal), + [typeof(StaffWorkspaceOnboardingIdentityAnchorResolutionResult)] = + new( + [nameof(StaffWorkspaceOnboardingIdentityAnchorResolutionResult + .Status)], + StringComparer.Ordinal) }; [Fact] @@ -92,6 +122,32 @@ public void Catalogue_version_matches_tenant_termination_contract() Catalogue.CatalogVersion); } + [Fact] + public void V20_catalogue_and_owner_manifest_have_immutable_digests() + { + string dataGovernanceDirectory = Path.Combine( + AppContext.BaseDirectory, + "DataGovernance"); + + Assert.Equal(5, StaffTenantTerminationMetadata.CatalogVersion); + Assert.Equal(20, StaffTenantTerminationMetadata.PersonalDataCatalogVersion); + Assert.Equal(5, StaffTenantTerminationMetadata.ExportSchemaVersion); + Assert.Equal(20, Catalogue.CatalogVersion); + Assert.Equal( + "998c761b8ec7eaa50c3b65bb8c28a2bb66b02f8be6757da2d927382d68d38386", + ComputeSha256(Path.Combine( + dataGovernanceDirectory, + "personal-data-catalog.v1.json"))); + Assert.Equal( + "51b3804d21f7ee7afff828284aeb00176127358ec95dd62ff026cf4cce2f058c", + StaffTenantTerminationMetadata.CatalogSha256); + Assert.Equal( + "2189f2b9eb648442616a152b93c8fd2f03b976dddda11717324b353946a42a16", + ComputeSha256(Path.Combine( + dataGovernanceDirectory, + "personal-data-inventory.v1.md"))); + } + [Fact] public void Every_catalogue_binding_resolves_to_a_real_member() { @@ -130,7 +186,9 @@ public void Every_staff_owned_personal_persistence_member_is_classified() typeof(StaffRetentionSweepCheckpoint), typeof(StaffRetentionAnonymisationReceipt), typeof(StaffOperationLock), - typeof(StaffMemberMutationOperation) + typeof(StaffMemberMutationOperation), + typeof(StaffIdentityProvisioningAnchor), + typeof(StaffIdentityProvisioningAnchorResolution) }) { IEntityType model = dbContext.Model.FindEntityType(entityType)!; @@ -189,6 +247,68 @@ public void Every_selected_command_query_api_admin_contract_and_event_member_is_ AssertType( typeof(StaffMemberMutationOperationDataRightsExport), PersonalDataSurface.DataRightsExport); + AssertType( + typeof(StaffIdentityProvisioningAnchorDataRightsExport), + PersonalDataSurface.DataRightsExport); + AssertType( + typeof(StaffIdentityProvisioningAnchorResolutionDataRightsExport), + PersonalDataSurface.DataRightsExport); + + AssertType( + typeof(StaffIdentityProvisioningAnchorRecord), + PersonalDataSurface.ApplicationCommand); + AssertType( + typeof(StaffIdentityProvisioningSourceKey), + PersonalDataSurface.ApplicationQuery); + AssertType( + typeof(StaffIdentityProvisioningAnchorResolutionRecord), + PersonalDataSurface.ApplicationCommand); + AssertType( + typeof(StaffMemberSafetyEvidence), + PersonalDataSurface.ApplicationQuery, + nameof(StaffMemberSafetyEvidence.Status)); + + foreach (Type type in new[] + { + typeof(StaffIdentityProvisioningAnchorCandidate), + typeof(StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest), + typeof(StaffWorkspaceOnboardingIdentityAnchorResolutionRequest) + }) + { + AssertType(type, PersonalDataSurface.IntegrationCommand); + } + + foreach (Type type in new[] + { + typeof(StaffIdentityProvisioningAnchorInspection), + typeof(StaffIdentityProvisioningAnchorCandidateInspection), + typeof(StaffIdentityProvisioningAnchorApplyResult), + typeof(StaffWorkspaceOnboardingIdentityAnchorOutcome), + typeof(StaffWorkspaceOnboardingIdentityAnchorResolutionResult) + }) + { + AssertType(type, PersonalDataSurface.ProjectionExport); + } + } + + [Fact] + public void Identity_provisioning_anchors_are_retained_until_tenant_termination() + { + PersonalDataRetentionPolicy retention = + Assert.Single( + Catalogue.RetentionPolicies, + policy => policy.Id == + "staff-identity-provisioning-anchor"); + PersonalDataRightsPolicy rights = + Assert.Single( + Catalogue.RightsPolicies, + policy => policy.Id == + "staff-identity-provisioning-anchor-control"); + + Assert.Equal("tenant-termination", retention.EndsAt); + Assert.Equal( + "retain-pseudonymous-linkage-until-tenant-termination", + rights.Erasure); } [Fact] @@ -426,6 +546,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/Staff/tests/BunkFy.Modules.Staff.Tests/Persistence/StaffDataRightsExportContributorTests.cs b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Persistence/StaffDataRightsExportContributorTests.cs index 95319eca..2c747048 100644 --- a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Persistence/StaffDataRightsExportContributorTests.cs +++ b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Persistence/StaffDataRightsExportContributorTests.cs @@ -80,6 +80,18 @@ public async Task Export_is_catalogue_versioned_and_contains_profile_governance_ StaffStatus.Active, member.Version, Now.AddMinutes(7)))); + Guid anchorSourceId = Guid.NewGuid(); + Guid anchorResolutionEventId = Guid.NewGuid(); + dbContext.IdentityProvisioningAnchors.Add( + new StaffIdentityProvisioningAnchor( + new StaffIdentityProvisioningAnchorRecord( + member.ScopeId, + StaffIdentityProvisioningSourceKind + .WorkspaceOnboarding, + anchorSourceId, + member.Id, + Now.AddMinutes(8), + anchorResolutionEventId))); dbContext.EmploymentGovernance.Add(governance); dbContext.DataHolds.AddRange(activeHold, releasedHold); await dbContext.SaveChangesAsync(); @@ -93,7 +105,7 @@ public async Task Export_is_catalogue_versioned_and_contains_profile_governance_ CancellationToken.None); Assert.Equal(DataRightsSubjectExportStatus.Succeeded, result.Status); - Assert.Equal(6, result.RecordCount); + Assert.Equal(7, result.RecordCount); Assert.Equal("staff.personal-data", contributor.Descriptor.CatalogId); Assert.Equal( StaffTenantTerminationMetadata.PersonalDataCatalogVersion, @@ -101,6 +113,7 @@ public async Task Export_is_catalogue_versioned_and_contains_profile_governance_ Assert.Equal( StaffDataRightsExportSchema.ExportSchemaId, contributor.Descriptor.ExportSchemaId); + Assert.Equal(4, contributor.Descriptor.ExportSchemaVersion); Assert.DoesNotContain("staff.scope-id", contributor.Descriptor.FieldIds); Assert.DoesNotContain("staff.audit-actor-id", contributor.Descriptor.FieldIds); Assert.DoesNotContain("staff.change-reason", contributor.Descriptor.FieldIds); @@ -110,6 +123,12 @@ public async Task Export_is_catalogue_versioned_and_contains_profile_governance_ Assert.DoesNotContain( "staff.data-hold.actor-id", contributor.Descriptor.FieldIds); + Assert.Contains( + "staff.identity-provisioning-anchor.source-kind", + contributor.Descriptor.FieldIds); + Assert.Contains( + "staff.identity-provisioning-anchor.resolution-event-id", + contributor.Descriptor.FieldIds); DataRightsExportRecord profileRecord = Assert.Single( sink.Records, @@ -187,6 +206,116 @@ record => record.RecordType == Assert.Equal( member.Version, updateOperationRecord.RecordVersion); + + DataRightsExportRecord anchorRecord = Assert.Single( + sink.Records, + record => record.RecordType == + StaffDataRightsExportContributor + .IdentityProvisioningAnchorRecordType); + Assert.Equal(1, anchorRecord.RecordVersion); + Assert.Equal(5, anchorRecord.Fields.Count); + Assert.InRange( + anchorRecord.Fields.Count, + 1, + DataRightsExportLimits.MaxFieldsPerRecord); + Assert.Equal( + member.Id, + Field( + anchorRecord, + "staff.identity-provisioning-anchor.staff-member-id") + .GetGuid()); + Assert.Equal( + "workspace-onboarding", + Field( + anchorRecord, + "staff.identity-provisioning-anchor.source-kind") + .GetString()); + Assert.Equal( + anchorSourceId, + Field( + anchorRecord, + "staff.identity-provisioning-anchor.source-id") + .GetGuid()); + Assert.Equal( + anchorResolutionEventId, + Field( + anchorRecord, + "staff.identity-provisioning-anchor.resolution-event-id") + .GetGuid()); + Assert.Equal( + Now.AddMinutes(8), + Field( + anchorRecord, + "staff.identity-provisioning-anchor.anchored-at-utc") + .GetDateTimeOffset()); + + CollectingSink replay = new(); + DataRightsSubjectExportResult replayResult = + await contributor.ExportAsync( + Request("tenant-a", member.Id, member.Version), + replay, + CancellationToken.None); + + Assert.Equal(DataRightsSubjectExportStatus.Succeeded, replayResult.Status); + Assert.Equal(result.RecordCount, replayResult.RecordCount); + Assert.Equal( + sink.Records.Select(RecordIdentity), + replay.Records.Select(RecordIdentity)); + } + + [Fact] + public async Task Export_keeps_anchor_after_profile_anonymisation_when_mutation_journal_is_absent() + { + await using StaffDbContext dbContext = CreateDbContext("tenant-a"); + StaffMember member = CreateMember("tenant-a"); + Assert.True(member.Depart( + new DateOnly(2026, 7, 26), + member.Version, + "user:manager", + "Employment ended.", + Guid.NewGuid(), + [], + Now.AddMinutes(1)).IsSuccess); + Assert.True(member.Anonymise( + member.Version, + "user:privacy", + Guid.NewGuid(), + Now.AddMinutes(2)).IsSuccess); + Guid sourceId = Guid.NewGuid(); + dbContext.StaffMembers.Add(member); + dbContext.IdentityProvisioningAnchors.Add( + new StaffIdentityProvisioningAnchor( + new StaffIdentityProvisioningAnchorRecord( + member.ScopeId, + StaffIdentityProvisioningSourceKind + .OrganizationMembership, + sourceId, + member.Id, + Now.AddMinutes(-1)))); + await dbContext.SaveChangesAsync(); + StaffDataRightsExportContributor contributor = + new(dbContext, new TestScopeContext("tenant-a")); + CollectingSink sink = new(); + + DataRightsSubjectExportResult result = await contributor.ExportAsync( + Request("tenant-a", member.Id, member.Version), + sink, + CancellationToken.None); + + Assert.Equal(DataRightsSubjectExportStatus.Succeeded, result.Status); + Assert.Equal(2, result.RecordCount); + Assert.Empty(await dbContext.MemberMutationOperations.ToListAsync()); + DataRightsExportRecord anchorRecord = Assert.Single( + sink.Records, + record => record.RecordType == + StaffDataRightsExportContributor + .IdentityProvisioningAnchorRecordType); + Assert.Equal( + sourceId, + Field( + anchorRecord, + "staff.identity-provisioning-anchor.source-id") + .GetGuid()); } [Fact] @@ -382,6 +511,17 @@ private static StaffEmploymentGovernance CreateGovernance( private static JsonElement Field(DataRightsExportRecord record, string fieldId) => Assert.Single(record.Fields, field => field.FieldId == fieldId).Value; + private static string RecordIdentity(DataRightsExportRecord record) => + string.Join( + '|', + record.RecordType, + record.RecordId, + record.RecordVersion, + string.Join( + ';', + record.Fields.Select(field => + $"{field.FieldId}={field.Value.GetRawText()}"))); + private static StaffDbContext CreateDbContext(string tenantId) { DbContextOptions options = diff --git a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Persistence/StaffTenantTerminationContributorTests.cs b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Persistence/StaffTenantTerminationContributorTests.cs index 5c3fa6d9..3d9e2bfb 100644 --- a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Persistence/StaffTenantTerminationContributorTests.cs +++ b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/Persistence/StaffTenantTerminationContributorTests.cs @@ -96,9 +96,59 @@ record => Field(record, "staff.member-mutation-operation") .GetProperty("requestFingerprint") .GetString() == Digest); + DataRightsExportRecord[] anchorRecords = first.Records + .Where(record => record.RecordType == + StaffTenantTerminationMetadata + .IdentityProvisioningAnchorRecordType) + .ToArray(); + Assert.Equal(2, anchorRecords.Length); + DataRightsExportRecord anchorRecord = Assert.Single( + anchorRecords, + record => Field( + record, + "staff.identity-provisioning-anchor") + .GetProperty("sourceId") + .GetGuid() == + Guid.Parse("80000000-0000-0000-0000-000000000002")); + Assert.Equal( + [ + "staff.identity-provisioning-anchor", + "staff.scope-id", + "staff.staff-member-id" + ], + anchorRecord.Fields + .Select(field => field.FieldId) + .OrderBy(field => field, StringComparer.Ordinal) + .ToArray()); + JsonElement anchorState = Field( + anchorRecord, + "staff.identity-provisioning-anchor"); + Assert.Equal(4, anchorState.EnumerateObject().Count()); + Assert.Equal( + "workspace-onboarding", + anchorState.GetProperty("sourceKind").GetString()); + Assert.Equal( + Guid.Parse("80000000-0000-0000-0000-000000000002"), + anchorState.GetProperty("sourceId").GetGuid()); + Assert.Equal( + Guid.Parse("81000000-0000-0000-0000-000000000002"), + anchorState.GetProperty("resolutionEventId").GetGuid()); + Assert.Equal( + Now.AddMinutes(3), + anchorState.GetProperty("anchoredAtUtc") + .GetDateTimeOffset()); Assert.Equal( StaffTenantTerminationMetadata.ExportSchemaId, contributor.ExportDescriptor.ExportSchemaId); + Assert.Equal( + StaffTenantTerminationMetadata.ExportSchemaVersion, + contributor.ExportDescriptor.ExportSchemaVersion); + Assert.All( + first.Records, + record => Assert.InRange( + record.Fields.Count, + 1, + DataRightsExportLimits.MaxFieldsPerRecord)); Assert.Equal( StaffTenantTerminationMetadata.ExportFieldIds .OrderBy(field => field, StringComparer.Ordinal), @@ -493,6 +543,44 @@ private static void SeedGraph(StaffDbContext context) StaffStatus.Active, member.Version, Now.AddMinutes(3)))); + context.IdentityProvisioningAnchors.AddRange( + new StaffIdentityProvisioningAnchor( + new StaffIdentityProvisioningAnchorRecord( + member.ScopeId, + StaffIdentityProvisioningSourceKind + .WorkspaceOnboarding, + Guid.Parse( + "80000000-0000-0000-0000-000000000001"), + member.Id, + Now.AddMinutes(3), + Guid.Parse( + "81000000-0000-0000-0000-000000000001"))), + new StaffIdentityProvisioningAnchor( + new StaffIdentityProvisioningAnchorRecord( + member.ScopeId, + StaffIdentityProvisioningSourceKind + .WorkspaceOnboarding, + Guid.Parse( + "80000000-0000-0000-0000-000000000002"), + member.Id, + Now.AddMinutes(3), + Guid.Parse( + "81000000-0000-0000-0000-000000000002")))); + context.IdentityProvisioningAnchorResolutions.Add( + new StaffIdentityProvisioningAnchorResolution( + new StaffIdentityProvisioningAnchorResolutionRecord( + member.ScopeId, + StaffIdentityProvisioningSourceKind + .WorkspaceOnboarding, + Guid.Parse( + "80000000-0000-0000-0000-000000000001"), + member.Id, + WorkspaceApplicationVersion: 4, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + Guid.Parse( + "81000000-0000-0000-0000-000000000001"), + Now.AddMinutes(4)))); context.DataRightsCorrectionReceipts.Add( StaffDataRightsCorrectionReceipt.Create( @@ -784,6 +872,8 @@ private static TenantTerminationContributionRequest DestroyRequest() => private static async Task HasOwnerRecordsAsync( StaffDbContext context) => await context.StaffMembers.AnyAsync() || + await context.IdentityProvisioningAnchors.AnyAsync() || + await context.IdentityProvisioningAnchorResolutions.AnyAsync() || await context.MemberMutationOperations.AnyAsync() || await context.DataRightsCorrectionReceipts.AnyAsync() || await context.ProcessingRestrictions.AnyAsync() || diff --git a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/StubStaffIdentityProvisioningAnchorResolutionRepository.cs b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/StubStaffIdentityProvisioningAnchorResolutionRepository.cs new file mode 100644 index 00000000..dc0202b7 --- /dev/null +++ b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/StubStaffIdentityProvisioningAnchorResolutionRepository.cs @@ -0,0 +1,47 @@ +namespace BunkFy.Modules.Staff.Tests; + +using BunkFy.Modules.Staff.Application.Ports; + +internal sealed class StubStaffIdentityProvisioningAnchorResolutionRepository( + bool hasUnresolved = false) + : IStaffIdentityProvisioningAnchorResolutionRepository +{ + private readonly Dictionary records = []; + + public Task GetAsync( + Guid sourceId, + CancellationToken cancellationToken) => + Task.FromResult(this.records.GetValueOrDefault(sourceId)); + + public Task HasUnresolvedWorkspaceOnboardingAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + Task.FromResult(hasUnresolved); + + public Task AddAsync( + StaffIdentityProvisioningAnchorResolutionRecord resolution, + CancellationToken cancellationToken) + { + this.records.Add(resolution.SourceId, resolution); + return Task.CompletedTask; + } +} + +internal sealed class StubStaffIdentityProvisioningAnchorWriter( + IStaffIdentityProvisioningAnchorRepository anchors) + : IStaffIdentityProvisioningAnchorWriter +{ + public async Task AddAsync( + StaffIdentityProvisioningAnchorRecord anchor, + CancellationToken cancellationToken) + { + StaffIdentityProvisioningAnchorRecord persisted = anchor.SourceKind == + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding && + !anchor.ResolutionEventId.HasValue + ? anchor with { ResolutionEventId = Guid.NewGuid() } + : anchor; + await anchors.AddAsync(persisted, cancellationToken); + return persisted; + } +} diff --git a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/StubStaffMemberRepository.cs b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/StubStaffMemberRepository.cs index 3e98d9de..a17a342c 100644 --- a/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/StubStaffMemberRepository.cs +++ b/src/Modules/Staff/tests/BunkFy.Modules.Staff.Tests/StubStaffMemberRepository.cs @@ -31,6 +31,11 @@ public Task AddAsync( CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetForSafetyTransitionByAuthSubjectAsync( + string authSubjectId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + public Task GetByAuthSubjectAsync( string authSubjectId, CancellationToken cancellationToken) => diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Admin.Contracts/Operations/WorkspacesAdminOperationNames.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Admin.Contracts/Operations/WorkspacesAdminOperationNames.cs index 0c784a8a..a898effc 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Admin.Contracts/Operations/WorkspacesAdminOperationNames.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Admin.Contracts/Operations/WorkspacesAdminOperationNames.cs @@ -6,4 +6,10 @@ public static class WorkspacesAdminOperationNames public const string AccessBootstrapRun = "workspaces.access-bootstrap.run"; public const string StaffAccessList = "workspaces.staff-access.list"; public const string StaffAccessRetry = "workspaces.staff-access.retry"; + public const string IdentityAnchorsStatus = + "workspaces.identity-anchors.status"; + public const string IdentityAnchorsReconcile = + "workspaces.identity-anchors.reconcile"; + public const string IdentityAnchorsHistoricalNoProvisionReview = + "workspaces.identity-anchors.historical-no-provision.review"; } diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Admin.Contracts/Permissions/WorkspacesAdminPermissions.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Admin.Contracts/Permissions/WorkspacesAdminPermissions.cs index 53e54a04..35ba98af 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Admin.Contracts/Permissions/WorkspacesAdminPermissions.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Admin.Contracts/Permissions/WorkspacesAdminPermissions.cs @@ -8,4 +8,10 @@ public static class WorkspacesAdminPermissions AdminPermission.Create("workspaces.access.bootstrap"); public static readonly AdminPermission StaffAccessManage = AdminPermission.Create("workspaces.staff-access.manage"); + public static readonly AdminPermission IdentityAnchorsReconcile = + AdminPermission.Create("workspaces.identity-anchors.reconcile"); + public static readonly AdminPermission + IdentityAnchorsHistoricalNoProvisionReview = + AdminPermission.Create( + "workspaces.identity-anchors.historical-no-provision.review"); } diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.AdminCli/Properties/AssemblyInfo.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.AdminCli/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..a12c3455 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.AdminCli/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("BunkFy.Modules.Workspaces.Tests")] diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.AdminCli/WorkspacesAdminCliModule.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.AdminCli/WorkspacesAdminCliModule.cs index a4882952..a74bf4d9 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.AdminCli/WorkspacesAdminCliModule.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.AdminCli/WorkspacesAdminCliModule.cs @@ -3,11 +3,13 @@ namespace BunkFy.Modules.Workspaces.AdminCli; using System.CommandLine; using System.CommandLine.Parsing; using System.Globalization; +using System.Text.Json; using BunkFy.Modules.Workspaces.Admin.Contracts; using BunkFy.Modules.Workspaces.Application; using BunkFy.Modules.Workspaces.Application.Commands; using BunkFy.Modules.Workspaces.Application.Queries; using BunkFy.Modules.Workspaces.Contracts; +using BunkFy.Modules.Workspaces.Domain; using BunkFy.Modules.Workspaces.Persistence; using Gma.Framework.Administration; using Gma.Framework.Administration.Cli; @@ -21,6 +23,9 @@ namespace BunkFy.Modules.Workspaces.AdminCli; public sealed class WorkspacesAdminCliModule : IAdminCliModule { + private const string ApplyOutcomeUnknownOperatorAction = + "Apply outcome is indeterminate. Archive this output and run identity-anchors status with the reviewed owner manifest; do not retry this reconcile request."; + public string Name => WorkspacesModuleMetadata.Name; public void AddServices(IHostApplicationBuilder builder) @@ -47,10 +52,21 @@ public void MapCommands(IAdminCliCommandRegistry commands) CreateStaffAccessListCommand(commands.Services, global), CreateStaffAccessRetryCommand(commands.Services, global) }; + Command identityAnchors = new( + "identity-anchors", + "Inspect and reconcile durable Staff identity provisioning anchors.") + { + CreateIdentityAnchorStatusCommand(commands.Services, global), + CreateIdentityAnchorReconcileCommand(commands.Services, global), + CreateIdentityAnchorHistoricalNoProvisionCommand( + commands.Services, + global) + }; Command module = new(WorkspacesModuleMetadata.Name, "Workspace composition administration operations.") { access, - staffAccess + staffAccess, + identityAnchors }; commands.AddCommand(this.Name, module); } @@ -222,4 +238,556 @@ private static Command CreateStaffAccessRetryCommand( cancellationToken)); return command; } + + private static Command CreateIdentityAnchorStatusCommand( + IServiceProvider services, + AdminCliGlobalOptions global) + { + Option ownerMap = new("--owner-map"); + Command command = new( + "status", + "Inspect the complete tenant identity-anchor source and state plan.") + { + ownerMap + }; + command.SetAction((parse, cancellationToken) => + services.GetRequiredService().ExecuteAsync( + parse, + AdminOperation.Create( + WorkspacesAdminOperationNames.IdentityAnchorsStatus, + WorkspacesAdminPermissions.IdentityAnchorsReconcile), + parse.GetValue(global.TenantOption), + requireTenant: true, + async (provider, token) => + { + Result manifest = + await ReadOwnerManifestAsync( + parse.GetValue(ownerMap), + required: false, + token).ConfigureAwait(false); + if (manifest.IsFailure) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorCutoverStatus>( + manifest.Error); + } + + Result result = + await provider.GetRequiredService() + .QueryAsync( + new GetWorkspaceStaffIdentityAnchorCutoverStatusQuery( + manifest.Value), + token).ConfigureAwait(false); + if (result.IsSuccess) + { + WriteIdentityAnchorStatus( + result.Value, + parse.GetValue(global.OutputOption) ?? + AdminCliOutput.Table); + } + + return result; + }, + cancellationToken)); + return command; + } + + private static Command CreateIdentityAnchorReconcileCommand( + IServiceProvider services, + AdminCliGlobalOptions global) + { + Option expectedSourceEvidenceSha256 = new( + "--expected-source-evidence-sha256") + { + Required = true + }; + Option expectedAnchorStateSha256 = new( + "--expected-anchor-state-sha256") + { + Required = true + }; + Option ownerMap = new("--owner-map") { Required = true }; + Option expectedOwnerManifestSha256 = new( + "--expected-owner-manifest-sha256") + { + Required = true + }; + Option batchSize = new("--batch-size") + { + DefaultValueFactory = _ => 100 + }; + Option yes = new("--yes"); + Command command = new( + "reconcile", + "Import one reviewed batch after a whole-tenant fail-closed plan.") + { + expectedSourceEvidenceSha256, + expectedAnchorStateSha256, + ownerMap, + expectedOwnerManifestSha256, + batchSize, + yes + }; + command.SetAction((parse, cancellationToken) => + services.GetRequiredService().ExecuteAsync( + parse, + AdminOperation.Create( + WorkspacesAdminOperationNames.IdentityAnchorsReconcile, + WorkspacesAdminPermissions.IdentityAnchorsReconcile), + parse.GetValue(global.TenantOption), + requireTenant: true, + async (provider, token) => + { + if (!parse.GetValue(yes)) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorReconcileResult>( + AdminErrors.ConfirmationRequired); + } + + Result manifest = + await ReadOwnerManifestAsync( + parse.GetValue(ownerMap), + required: true, + token).ConfigureAwait(false); + if (manifest.IsFailure || manifest.Value is null) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorReconcileResult>( + manifest.IsFailure + ? manifest.Error + : WorkspaceStaffIdentityAnchorCutoverErrors + .OwnerManifestRequired); + } + + Result result = + await provider.GetRequiredService() + .SendAsync( + new ReconcileWorkspaceStaffIdentityAnchorsCommand( + parse.GetValue( + expectedSourceEvidenceSha256)!, + parse.GetValue( + expectedAnchorStateSha256)!, + manifest.Value, + parse.GetValue( + expectedOwnerManifestSha256)!, + parse.GetValue(batchSize)), + token).ConfigureAwait(false); + if (result.IsSuccess) + { + string output = parse.GetValue(global.OutputOption) ?? + AdminCliOutput.Table; + bool verified = + IsVerifiedIdentityAnchorReconcileResult( + result.Value); + WorkspaceStaffIdentityAnchorReconcileResult printable = + verified + ? result.Value + : result.Value with + { + Outcome = + WorkspaceStaffIdentityAnchorReconcileOutcome + .ApplyOutcomeUnknown, + MustRerunStatus = true + }; + if (printable.Status is not null) + { + WriteIdentityAnchorStatus( + printable.Status, + output, + printable.AppliedCount, + printable); + } + else + { + WriteIdentityAnchorApplyOutcomeUnknown( + printable, + output); + } + + if (!verified) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorReconcileResult>( + WorkspaceStaffIdentityAnchorCutoverErrors + .ApplyOutcomeUnknown); + } + } + + return result; + }, + cancellationToken)); + return command; + } + + private static Command CreateIdentityAnchorHistoricalNoProvisionCommand( + IServiceProvider services, + AdminCliGlobalOptions global) + { + Option operationId = new("--operation-id") { Required = true }; + Option applicationId = new("--application-id") + { + Required = true + }; + Option applicationVersion = new("--application-version") + { + Required = true + }; + Option applicationStatus = new( + "--application-status") + { + Required = true + }; + Option organizationsRevision = new("--organizations-revision") + { + Required = true + }; + Option organizationsSourceVersion = new( + "--organizations-source-version") + { + Required = true + }; + Option + organizationsSourceStatus = new( + "--organizations-source-status") + { + Required = true + }; + Option evidenceManifestId = new("--evidence-manifest-id") + { + Required = true + }; + Option evidenceSha256 = new("--evidence-sha256") + { + Required = true + }; + Option yes = new("--yes"); + Command command = new( + "review-historical-no-provision", + "Durably classify one source-terminal onboarding as having no Staff authority.") + { + operationId, + applicationId, + applicationVersion, + applicationStatus, + organizationsRevision, + organizationsSourceVersion, + organizationsSourceStatus, + evidenceManifestId, + evidenceSha256, + yes + }; + command.SetAction((parse, cancellationToken) => + services.GetRequiredService().ExecuteAsync( + parse, + AdminOperation.Create( + WorkspacesAdminOperationNames + .IdentityAnchorsHistoricalNoProvisionReview, + WorkspacesAdminPermissions + .IdentityAnchorsHistoricalNoProvisionReview), + parse.GetValue(global.TenantOption), + requireTenant: true, + async (provider, token) => + { + Guid expectedOperationId = parse.GetValue(operationId); + Guid expectedApplicationId = parse.GetValue(applicationId); + long expectedApplicationVersion = parse.GetValue( + applicationVersion); + WorkspaceStaffOnboardingState expectedApplicationStatus = + parse.GetValue(applicationStatus); + string reviewerId = ResolveHistoricalReviewActor(provider); + Result + result = parse.GetValue(yes) + ? await provider + .GetRequiredService() + .SendAsync( + new + ReviewWorkspaceStaffHistoricalNoProvisionCommand( + expectedOperationId, + expectedApplicationId, + expectedApplicationVersion, + expectedApplicationStatus, + parse.GetValue( + organizationsRevision), + parse.GetValue( + organizationsSourceVersion), + parse.GetValue( + organizationsSourceStatus), + parse.GetValue(evidenceManifestId), + parse.GetValue(evidenceSha256) ?? + string.Empty, + reviewerId), + token) + .ConfigureAwait(false) + : Result.Failure< + WorkspaceStaffHistoricalNoProvisionDispositionResult>( + AdminErrors.ConfirmationRequired); + if (result.IsSuccess && + (!IsWellFormedHistoricalNoProvisionResult( + result.Value, + expectedApplicationVersion, + expectedApplicationStatus) || + result.Value.OperationId != expectedOperationId || + result.Value.ApplicationId != + expectedApplicationId)) + { + result = Result.Failure< + WorkspaceStaffHistoricalNoProvisionDispositionResult>( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .Conflict); + } + + if (result.IsSuccess) + { + AdminCliOutput.WriteRows( + [result.Value], + parse.GetValue(global.OutputOption) ?? + AdminCliOutput.Table, + [ + ("ReceiptId", item => item.ReceiptId.ToString("D")), + ("OperationId", item => item.OperationId.ToString("D")), + ("ApplicationId", item => item.ApplicationId.ToString("D")), + ("ApplicationVersion", item => + item.ResultApplicationVersion.ToString( + CultureInfo.InvariantCulture)), + ("ApplicationStatus", item => + item.ResultApplicationStatus.ToString()), + ("StaffEvidenceSha256", item => + item.StaffEvidenceSha256), + ("CanonicalSha256", item => + item.CanonicalSha256), + ("ReviewedAtUtc", item => + item.ReviewedAtUtc.ToUniversalTime() + .ToString( + "O", + CultureInfo.InvariantCulture)), + ("AlreadyReviewed", item => + item.AlreadyReviewed.ToString()) + ]); + } + + return result; + }, + cancellationToken)); + return command; + } + + internal static bool IsWellFormedHistoricalNoProvisionResult( + WorkspaceStaffHistoricalNoProvisionDispositionResult result, + long expectedApplicationVersion, + WorkspaceStaffOnboardingState expectedApplicationStatus) => + result is not null && + result.ReceiptId != Guid.Empty && + result.OperationId != Guid.Empty && + result.ApplicationId != Guid.Empty && + expectedApplicationVersion is >= 1 and < long.MaxValue && + Enum.IsDefined(expectedApplicationStatus) && + expectedApplicationStatus != WorkspaceStaffOnboardingState.Unknown && + IsExpectedHistoricalNoProvisionTransition( + expectedApplicationVersion, + expectedApplicationStatus, + result.ResultApplicationVersion, + result.ResultApplicationStatus) && + IsSha256(result.StaffEvidenceSha256) && + IsSha256(result.CanonicalSha256) && + result.ReviewedAtUtc != default; + + internal static string ResolveHistoricalReviewActor( + IServiceProvider provider) => provider + .GetRequiredService() + .Actor?.Id ?? string.Empty; + + private static bool IsExpectedHistoricalNoProvisionTransition( + long expectedVersion, + WorkspaceStaffOnboardingState expectedStatus, + long resultVersion, + WorkspaceStaffOnboardingState resultStatus) => + expectedStatus is WorkspaceStaffOnboardingState.Completed or + WorkspaceStaffOnboardingState.Rejected or + WorkspaceStaffOnboardingState.Superseded or + WorkspaceStaffOnboardingState.Expired or + WorkspaceStaffOnboardingState.Withdrawn + ? resultStatus == expectedStatus && + resultVersion >= expectedVersion && + resultVersion <= expectedVersion + 1 + : resultStatus == WorkspaceStaffOnboardingState.Superseded && + resultVersion == expectedVersion + 1; + + internal static bool IsVerifiedIdentityAnchorReconcileResult( + WorkspaceStaffIdentityAnchorReconcileResult result) => + result.Outcome == + WorkspaceStaffIdentityAnchorReconcileOutcome.AppliedAndVerified && + result.AppliedCount is >= 0 && + result.Status is not null && + !result.MustRerunStatus && + IsSha256(result.AcceptedSourceEvidenceSha256) && + IsSha256(result.AcceptedAnchorStateSha256) && + IsSha256(result.AcceptedOwnerManifestSha256) && + string.Equals( + result.AcceptedSourceEvidenceSha256, + result.Status.SourceEvidenceSha256, + StringComparison.OrdinalIgnoreCase) && + result.Status.OwnerManifestProvided && + string.Equals( + result.AcceptedOwnerManifestSha256, + result.Status.OwnerManifestSha256, + StringComparison.OrdinalIgnoreCase); + + private static bool IsSha256(string? value) => + value is { Length: 64 } && value.All(Uri.IsHexDigit); + + private static async Task> ReadOwnerManifestAsync( + string? path, + bool required, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(path)) + { + return required + ? Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .OwnerManifestRequired) + : Result.Success< + WorkspaceStaffIdentityAnchorOwnerManifest?>(null); + } + + try + { + await using FileStream stream = File.OpenRead(path); + WorkspaceStaffIdentityAnchorOwnerManifest? manifest = + await JsonSerializer.DeserializeAsync< + WorkspaceStaffIdentityAnchorOwnerManifest>( + stream, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + return manifest is null + ? Result.Failure< + WorkspaceStaffIdentityAnchorOwnerManifest?>( + WorkspaceStaffIdentityAnchorCutoverErrors + .OwnerManifestInvalid) + : Result.Success< + WorkspaceStaffIdentityAnchorOwnerManifest?>(manifest); + } + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException or + JsonException) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorOwnerManifest?>( + WorkspaceStaffIdentityAnchorCutoverErrors + .OwnerManifestInvalid); + } + } + + private static void WriteIdentityAnchorStatus( + WorkspaceStaffIdentityAnchorCutoverStatus status, + string output, + int? appliedCount = null, + WorkspaceStaffIdentityAnchorReconcileResult? reconcile = null) => + AdminCliOutput.WriteRows( + [new IdentityAnchorStatusOutput( + appliedCount, + status, + reconcile?.Outcome.ToString(), + reconcile?.MustRerunStatus, + reconcile?.AcceptedSourceEvidenceSha256, + reconcile?.AcceptedAnchorStateSha256, + reconcile?.AcceptedOwnerManifestSha256, + reconcile?.Outcome == + WorkspaceStaffIdentityAnchorReconcileOutcome + .ApplyOutcomeUnknown + ? ApplyOutcomeUnknownOperatorAction + : null)], + output, + [ + ("Applied", item => item.AppliedCount?.ToString( + CultureInfo.InvariantCulture) ?? string.Empty), + ("Outcome", item => item.Outcome ?? string.Empty), + ("MustRerunStatus", item => + item.MustRerunStatus?.ToString() ?? string.Empty), + ("WorkspaceSources", item => item.Status.WorkspaceSourceCount + .ToString(CultureInfo.InvariantCulture)), + ("OwnerBindings", item => item.Status.OwnerBindingCount + .ToString(CultureInfo.InvariantCulture)), + ("AlreadyAnchored", item => item.Status.AlreadyAnchoredCount + .ToString(CultureInfo.InvariantCulture)), + ("SeedableWorkspace", item => item.Status.SeedableWorkspaceCount + .ToString(CultureInfo.InvariantCulture)), + ("SeedableOwners", item => item.Status.SeedableOwnerCount + .ToString(CultureInfo.InvariantCulture)), + ("Ambiguous", item => item.Status.AmbiguousCount + .ToString(CultureInfo.InvariantCulture)), + ("Conflicts", item => item.Status.ConflictCount + .ToString(CultureInfo.InvariantCulture)), + ("Issues", item => item.Status.TotalIssueCount + .ToString(CultureInfo.InvariantCulture)), + ("HasMoreIssues", item => + item.Status.HasMoreIssues.ToString()), + ("SourceEvidenceSha256", item => + item.Status.SourceEvidenceSha256), + ("AnchorStateSha256", item => item.Status.AnchorStateSha256), + ("OwnerManifestSha256", item => + item.Status.OwnerManifestSha256 ?? string.Empty), + ("HistoricalEvidenceKind", item => + item.Status.HistoricalEvidenceKind.ToString()), + ("HistoricalEvidenceSha256", item => + item.Status.HistoricalEvidenceSha256 ?? string.Empty), + ("CanReconcile", item => item.Status.CanReconcile.ToString()), + ("Ready", item => item.Status.IsReady.ToString()) + ,("AcceptedSourceEvidenceSha256", item => + item.AcceptedSourceEvidenceSha256 ?? string.Empty) + ,("AcceptedAnchorStateSha256", item => + item.AcceptedAnchorStateSha256 ?? string.Empty) + ,("AcceptedOwnerManifestSha256", item => + item.AcceptedOwnerManifestSha256 ?? string.Empty) + ,("OperatorAction", item => item.OperatorAction ?? string.Empty) + ]); + + private sealed record IdentityAnchorStatusOutput( + int? AppliedCount, + WorkspaceStaffIdentityAnchorCutoverStatus Status, + string? Outcome, + bool? MustRerunStatus, + string? AcceptedSourceEvidenceSha256, + string? AcceptedAnchorStateSha256, + string? AcceptedOwnerManifestSha256, + string? OperatorAction); + + private static void WriteIdentityAnchorApplyOutcomeUnknown( + WorkspaceStaffIdentityAnchorReconcileResult result, + string output) => + AdminCliOutput.WriteRows( + [new IdentityAnchorApplyOutcomeUnknownOutput( + result.AppliedCount, + result.Outcome.ToString(), + result.MustRerunStatus, + result.AcceptedSourceEvidenceSha256, + result.AcceptedAnchorStateSha256, + result.AcceptedOwnerManifestSha256, + ApplyOutcomeUnknownOperatorAction)], + output, + [ + ("Applied", item => item.AppliedCount?.ToString( + CultureInfo.InvariantCulture) ?? string.Empty), + ("Outcome", item => item.Outcome), + ("MustRerunStatus", item => + item.MustRerunStatus.ToString()), + ("AcceptedSourceEvidenceSha256", item => + item.AcceptedSourceEvidenceSha256), + ("AcceptedAnchorStateSha256", item => + item.AcceptedAnchorStateSha256), + ("AcceptedOwnerManifestSha256", item => + item.AcceptedOwnerManifestSha256), + ("OperatorAction", item => item.OperatorAction) + ]); + + private sealed record IdentityAnchorApplyOutcomeUnknownOutput( + int? AppliedCount, + string Outcome, + bool MustRerunStatus, + string AcceptedSourceEvidenceSha256, + string AcceptedAnchorStateSha256, + string AcceptedOwnerManifestSha256, + string OperatorAction); } diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Api/WorkspaceStaffOnboardingDataRightsEndpoints.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Api/WorkspaceStaffOnboardingDataRightsEndpoints.cs index f4bd5432..4e3cbc93 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Api/WorkspaceStaffOnboardingDataRightsEndpoints.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Api/WorkspaceStaffOnboardingDataRightsEndpoints.cs @@ -2,6 +2,7 @@ namespace BunkFy.Modules.Workspaces.Api; using BunkFy.Modules.DataRights.Contracts; using BunkFy.Modules.Workspaces.Api.Requests; +using BunkFy.Modules.Workspaces.Application; using BunkFy.Modules.Workspaces.Application.Commands; using BunkFy.Modules.Workspaces.Application.Queries; using BunkFy.Modules.Workspaces.Contracts; @@ -11,6 +12,7 @@ namespace BunkFy.Modules.Workspaces.Api; using Gma.Framework.Api.Results; using Gma.Framework.Api.Tenancy; using Gma.Framework.Cqrs; +using Gma.Framework.Results; using Gma.Framework.Tenancy.AccessControl.AspNetCore; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -82,7 +84,8 @@ public static void Map( return Results.Unauthorized(); } - return (await dispatcher.SendAsync( + Result + outcome = await dispatcher.SendAsync( new ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommand( request.ExecutionId, request.CaseId, @@ -97,8 +100,9 @@ public static void Map( request.JobTitle, request.Department, FormatActor(actor)), - cancellationToken).ConfigureAwait(false)) - .ToHttpResult(WorkspacesApiEndpointSupport.ErrorStatusCodes); + cancellationToken).ConfigureAwait(false); + return MapCorrectionOutcome(outcome).ToHttpResult( + WorkspacesApiEndpointSupport.ErrorStatusCodes); }) .Produces< WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( @@ -110,4 +114,35 @@ public static void Map( private static string FormatActor(AccessSubject actor) => $"{AccessSubjectKindNames.GetName(actor.Kind)}:{actor.Id}"; + + internal static Result + MapCorrectionOutcome( + Result + outcome) + { + if (outcome.IsFailure) + { + return Result.Failure< + WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( + outcome.Error); + } + + return outcome.Value.Kind switch + { + WorkspaceStaffOnboardingDataRightsCorrectionOutcomeKind.Applied + when outcome.Value.Receipt is not null => + Result.Success(outcome.Value.Receipt), + WorkspaceStaffOnboardingDataRightsCorrectionOutcomeKind + .AuthorityMovedToStaff + when outcome.Value.Receipt is null => + Result.Failure< + WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( + WorkspaceStaffOnboardingApplicationErrors + .CorrectionTargetUnavailable), + _ => Result.Failure< + WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorConflict) + }; + } } diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Api/WorkspaceStaffOnboardingEndpoints.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Api/WorkspaceStaffOnboardingEndpoints.cs index 82f827ec..70b9d585 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Api/WorkspaceStaffOnboardingEndpoints.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Api/WorkspaceStaffOnboardingEndpoints.cs @@ -12,6 +12,7 @@ namespace BunkFy.Modules.Workspaces.Api; using Gma.Framework.Api.Tenancy; using Gma.Framework.Cqrs; using Gma.Framework.Pagination; +using Gma.Framework.Results; using Gma.Framework.Scoping; using Gma.Framework.Security; using Gma.Framework.Security.AspNetCore; @@ -47,7 +48,8 @@ public static void Map( return Results.Unauthorized(); } - return (await submitter.SubmitAsync( + Result outcome = + await submitter.SubmitWithAuthorityOutcomeAsync( new SubmitWorkspaceStaffOnboardingCommand( request.SourceKind, request.Token, @@ -59,8 +61,9 @@ public static void Map( request.EmployeeNumber, request.JobTitle, request.Department), - token).ConfigureAwait(false)).ToHttpResult( - WorkspacesApiEndpointSupport.ErrorStatusCodes); + token).ConfigureAwait(false); + return MapSubmissionOutcome(outcome).ToHttpResult( + WorkspacesApiEndpointSupport.ErrorStatusCodes); }).Produces(StatusCodes.Status200OK); group.MapPost("/sources/invitations", async ( @@ -350,6 +353,31 @@ public static void Map( .Produces(StatusCodes.Status200OK); } + internal static Result MapSubmissionOutcome( + Result outcome) + { + if (outcome.IsFailure) + { + return Result.Failure(outcome.Error); + } + + return outcome.Value.Kind switch + { + WorkspaceStaffOnboardingSubmissionOutcomeKind.Applied + when outcome.Value.Application is not null => + Result.Success(outcome.Value.Application), + WorkspaceStaffOnboardingSubmissionOutcomeKind + .AuthorityMovedToStaff + when outcome.Value.Application is null => + Result.Failure( + WorkspaceStaffOnboardingApplicationErrors + .ProfileMutationAuthorityUnavailable), + _ => Result.Failure( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorConflict) + }; + } + private static RouteHandlerBuilder RequireAssuranceWhenConfigured( this RouteHandlerBuilder endpoint, AuthenticationAssuranceRequirement? requirement) => 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/BunkFy.Modules.Workspaces.Application.csproj b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/BunkFy.Modules.Workspaces.Application.csproj index 1298679a..a248185d 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/BunkFy.Modules.Workspaces.Application.csproj +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/BunkFy.Modules.Workspaces.Application.csproj @@ -29,5 +29,6 @@ + diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/AdvanceWorkspaceStaffIdentityAnchorSweepCommand.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/AdvanceWorkspaceStaffIdentityAnchorSweepCommand.cs new file mode 100644 index 00000000..ea8e492d --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/AdvanceWorkspaceStaffIdentityAnchorSweepCommand.cs @@ -0,0 +1,8 @@ +namespace BunkFy.Modules.Workspaces.Application.Commands; + +using BunkFy.Modules.Workspaces.Application.Models; +using Gma.Framework.Cqrs; + +public sealed record AdvanceWorkspaceStaffIdentityAnchorSweepCommand( + WorkspaceStaffIdentityAnchorSweepAdvance Advance) + : ITransactionalCommand; diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommand.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommand.cs index bae47964..3e0a753d 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommand.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommand.cs @@ -18,4 +18,29 @@ public sealed record ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommand( string? Department, string ActorId) : ITransactionalCommand< - WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>; + WorkspaceStaffOnboardingDataRightsCorrectionOutcome>; + +public sealed record WorkspaceStaffOnboardingDataRightsCorrectionOutcome( + WorkspaceStaffOnboardingDataRightsCorrectionOutcomeKind Kind, + WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto? Receipt) +{ + public static WorkspaceStaffOnboardingDataRightsCorrectionOutcome Applied( + WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto receipt) => + new( + WorkspaceStaffOnboardingDataRightsCorrectionOutcomeKind.Applied, + receipt ?? throw new ArgumentNullException(nameof(receipt))); + + public static WorkspaceStaffOnboardingDataRightsCorrectionOutcome + AuthorityMovedToStaff() => + new( + WorkspaceStaffOnboardingDataRightsCorrectionOutcomeKind + .AuthorityMovedToStaff, + Receipt: null); +} + +public enum WorkspaceStaffOnboardingDataRightsCorrectionOutcomeKind +{ + Unknown = 0, + Applied = 1, + AuthorityMovedToStaff = 2 +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/PrepareWorkspaceStaffAccessCommand.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/PrepareWorkspaceStaffAccessCommand.cs index 7d724de8..c2c33a0e 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/PrepareWorkspaceStaffAccessCommand.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/PrepareWorkspaceStaffAccessCommand.cs @@ -1,6 +1,7 @@ namespace BunkFy.Modules.Workspaces.Application.Commands; using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Contracts; using Gma.Framework.Cqrs; public sealed record PrepareWorkspaceStaffAccessCommand( @@ -9,4 +10,5 @@ public sealed record PrepareWorkspaceStaffAccessCommand( public sealed record WorkspaceStaffAccessPreparation( Guid ProcessId, - bool RequiresAccessDenial); + bool RequiresAccessDenial, + WorkspaceStaffAccessRestorationDisposition RestorationDisposition); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/PrepareWorkspaceStaffIdentityAnchorSweepPageCommand.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/PrepareWorkspaceStaffIdentityAnchorSweepPageCommand.cs new file mode 100644 index 00000000..06d10d8f --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/PrepareWorkspaceStaffIdentityAnchorSweepPageCommand.cs @@ -0,0 +1,12 @@ +namespace BunkFy.Modules.Workspaces.Application.Commands; + +using BunkFy.Modules.Workspaces.Application.Models; +using Gma.Framework.Cqrs; + +public sealed record PrepareWorkspaceStaffIdentityAnchorSweepPageCommand( + Guid CheckpointId, + Guid CycleId, + Guid EmptyAdvanceId, + Guid RunId, + int BatchSize) + : ITransactionalCommand; diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommand.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommand.cs new file mode 100644 index 00000000..44dd54ad --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommand.cs @@ -0,0 +1,8 @@ +namespace BunkFy.Modules.Workspaces.Application.Commands; + +using BunkFy.Modules.Workspaces.Application.Models; +using Gma.Framework.Cqrs; + +public sealed record ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommand( + Guid ApplicationId) + : ITransactionalCommand; diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/ReconcileWorkspaceStaffIdentityAnchorsCommand.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/ReconcileWorkspaceStaffIdentityAnchorsCommand.cs new file mode 100644 index 00000000..dc0a2c2a --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/ReconcileWorkspaceStaffIdentityAnchorsCommand.cs @@ -0,0 +1,11 @@ +namespace BunkFy.Modules.Workspaces.Application.Commands; + +using Gma.Framework.Cqrs; + +public sealed record ReconcileWorkspaceStaffIdentityAnchorsCommand( + string ExpectedSourceEvidenceSha256, + string ExpectedAnchorStateSha256, + WorkspaceStaffIdentityAnchorOwnerManifest OwnerManifest, + string ExpectedOwnerManifestSha256, + int BatchSize) + : ICommand; diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/ReviewWorkspaceStaffHistoricalNoProvisionCommand.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/ReviewWorkspaceStaffHistoricalNoProvisionCommand.cs new file mode 100644 index 00000000..de8dea57 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/ReviewWorkspaceStaffHistoricalNoProvisionCommand.cs @@ -0,0 +1,19 @@ +namespace BunkFy.Modules.Workspaces.Application.Commands; + +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Cqrs; + +public sealed record ReviewWorkspaceStaffHistoricalNoProvisionCommand( + Guid OperationId, + Guid ApplicationId, + long ExpectedApplicationVersion, + WorkspaceStaffOnboardingState ExpectedApplicationStatus, + long ExpectedOrganizationsScopeRevision, + long ExpectedOrganizationsSourceVersion, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + ExpectedOrganizationsSourceStatus, + Guid ExternalEvidenceManifestId, + string ExternalEvidenceSha256, + string ReviewerId) + : ITransactionalCommand< + WorkspaceStaffHistoricalNoProvisionDispositionResult>; diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/SubmitWorkspaceStaffOnboardingCommand.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/SubmitWorkspaceStaffOnboardingCommand.cs index 05cf5fe7..0cd58d3e 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/SubmitWorkspaceStaffOnboardingCommand.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Commands/SubmitWorkspaceStaffOnboardingCommand.cs @@ -13,4 +13,30 @@ public sealed record SubmitWorkspaceStaffOnboardingCommand( string? WorkPhone, string? EmployeeNumber, string? JobTitle, - string? Department) : ITransactionalCommand; + string? Department) + : ITransactionalCommand; + +public sealed record WorkspaceStaffOnboardingSubmissionOutcome( + WorkspaceStaffOnboardingSubmissionOutcomeKind Kind, + WorkspaceStaffOnboardingDto? Application) +{ + public static WorkspaceStaffOnboardingSubmissionOutcome Applied( + WorkspaceStaffOnboardingDto application) => + new( + WorkspaceStaffOnboardingSubmissionOutcomeKind.Applied, + application ?? throw new ArgumentNullException(nameof(application))); + + public static WorkspaceStaffOnboardingSubmissionOutcome + AuthorityMovedToStaff() => + new( + WorkspaceStaffOnboardingSubmissionOutcomeKind + .AuthorityMovedToStaff, + Application: null); +} + +public enum WorkspaceStaffOnboardingSubmissionOutcomeKind +{ + Unknown = 0, + Applied = 1, + AuthorityMovedToStaff = 2 +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Contributors/WorkspaceStaffAnonymisationAccessPrerequisite.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Contributors/WorkspaceStaffAnonymisationAccessPrerequisite.cs index 76ca4a82..d82a662d 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Contributors/WorkspaceStaffAnonymisationAccessPrerequisite.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Contributors/WorkspaceStaffAnonymisationAccessPrerequisite.cs @@ -160,6 +160,9 @@ await correlations.GetAsync( return StaffRetentionAnonymisationPrerequisiteResult .Blocked(CorrelationReceiptInvalid); } + + return StaffRetentionAnonymisationPrerequisiteResult + .Completed(); } Result scrubbed = diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/DependencyInjection.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/DependencyInjection.cs index c4f900d5..c96605f8 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/DependencyInjection.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/DependencyInjection.cs @@ -11,6 +11,7 @@ namespace BunkFy.Modules.Workspaces.Application; using BunkFy.Modules.Workspaces.Application.Authorization; using BunkFy.Modules.Workspaces.Application.Contributors; using BunkFy.Modules.Workspaces.Application.Handlers; +using BunkFy.Modules.Workspaces.Application.Ports; using BunkFy.Modules.Workspaces.Application.Tasks; using BunkFy.Modules.Workspaces.Contracts; using Gma.Framework.AccessControl; @@ -56,6 +57,15 @@ public static IServiceCollection AddWorkspacesApplication( IWorkspaceStaffOnboardingSubmitter, WorkspaceStaffOnboardingSubmitter>(); services.TryAddScoped(); + services.TryAddScoped< + WorkspaceStaffOnboardingIdentityAnchorConvergence>(); + services.TryAddScoped< + IWorkspaceStaffIdentityAnchorFreshStatusReader, + WorkspaceStaffIdentityAnchorFreshStatusReader>(); + services.TryAddScoped< + WorkspaceStaffIdentityAnchorCutoverCoordinator>(); + services.TryAddScoped< + WorkspaceStaffHistoricalNoProvisionAuthorityReader>(); services.TryAddScoped(); services.TryAddScoped< WorkspaceStaffOnboardingDataRightsCorrectionAuthorizer>(); @@ -184,6 +194,16 @@ public static IServiceCollection AddWorkspacesApplication( StaffLifecycleWorkspaceAccessHandler>( WorkspacesModuleMetadata.Name, StaffModuleMetadata.Name); + services.AddIntegrationEventHandler< + StaffIdentityProvisioningAnchorCreatedIntegrationEvent, + StaffIdentityProvisioningAnchorCreatedWorkspacesHandler>( + WorkspacesModuleMetadata.Name, + StaffModuleMetadata.Name); + services.AddIntegrationEventHandler< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent, + WorkspaceStaffOnboardingIdentityAnchorContinuationHandler>( + WorkspacesModuleMetadata.Name, + WorkspacesModuleMetadata.Name); services.AddIntegrationEventHandler< PropertyCreatedIntegrationEvent, WorkspacePropertyCreatedHandler>( @@ -224,6 +244,17 @@ public static IServiceCollection AddWorkspacesTaskHandlers(this IServiceCollecti services.AddTaskHandler< RebuildWorkspacePropertiesPayload, RebuildWorkspacePropertiesTaskHandler>(WorkspacesModuleMetadata.Name); + services.AddTaskHandler< + ReconcileWorkspaceStaffIdentityAnchorsPayload, + WorkspaceStaffIdentityAnchorSweepTaskHandler>( + WorkspacesModuleMetadata.Name); + services.TryAddScoped< + IWorkspaceStaffIdentityAnchorSweepTransactionDispatcher, + WorkspaceStaffIdentityAnchorSweepTransactionDispatcher>(); + services.TryAddEnumerable( + ServiceDescriptor.Scoped< + ITaskScheduleProvider, + WorkspaceStaffIdentityAnchorSweepScheduleProvider>()); return services; } } diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ApplyWorkspaceStaffCorrelationAnonymisationCommandHandler.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ApplyWorkspaceStaffCorrelationAnonymisationCommandHandler.cs index 50862e53..395f45ab 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ApplyWorkspaceStaffCorrelationAnonymisationCommandHandler.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ApplyWorkspaceStaffCorrelationAnonymisationCommandHandler.cs @@ -19,6 +19,8 @@ internal sealed class ApplyWorkspaceStaffCorrelationAnonymisationCommandHandler( IWorkspaceStaffCorrelationAnonymisationRepository correlations, + IWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence + identityAnchors, IWorkspaceCrossGraphMutationLock crossGraphLock, WorkspaceStaffAccessMutationCoordinator mutations, IWorkspaceStaffCorrelationOperationLock operationLock, @@ -153,6 +155,16 @@ await correlations.ReadAsync( .StateChanged); } + if (!await identityAnchors.CanMutateAsync( + tenantId, + snapshot.SubjectId, + cancellationToken).ConfigureAwait(false)) + { + return Failure( + WorkspaceStaffCorrelationAnonymisationApplicationErrors + .IdentityAnchorUnavailable); + } + string? stateBindingSha256 = WorkspaceStaffCorrelationAnonymisationPolicyEvidence .GetFrozenBindingSha256( 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..e126810b 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler.cs @@ -12,22 +12,42 @@ 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, + WorkspaceStaffOnboardingIdentityAnchorConvergence anchorConvergence, WorkspaceStaffOnboardingDataRightsCorrectionAuthorizer authorizer, + IOrganizationEnrollmentClaimInspector claims, IScopeContext scopeContext, ISystemClock clock, IIdGenerator ids) : ICommandHandler< ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommand, - WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto> + WorkspaceStaffOnboardingDataRightsCorrectionOutcome> { + public async Task> HandleAsync( + ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommand command, + CancellationToken cancellationToken) => + Map( + await this.HandleWithAuthorityOutcomeAsync( + command, + cancellationToken).ConfigureAwait(false)); + + Task> + ICommandHandler< + ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommand, + WorkspaceStaffOnboardingDataRightsCorrectionOutcome>.HandleAsync( + ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommand command, + CancellationToken cancellationToken) => + this.HandleWithAuthorityOutcomeAsync(command, cancellationToken); + public async Task< - Result> - HandleAsync( + Result> + HandleWithAuthorityOutcomeAsync( ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommand command, CancellationToken cancellationToken) { @@ -40,9 +60,7 @@ public async Task< command.ActorId); if (valid.IsFailure) { - return Result.Failure< - WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( - valid.Error); + return Failure(valid.Error); } Result requested = @@ -56,9 +74,7 @@ public async Task< command.Department); if (requested.IsFailure) { - return Result.Failure< - WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( - requested.Error); + return Failure(requested.Error); } string requestSha256 = @@ -83,9 +99,7 @@ await receipts.FindByExecutionIdAsync( cancellationToken).ConfigureAwait(false); if (authorized.IsFailure) { - return Result.Failure< - WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( - authorized.Error); + return Failure(authorized.Error); } WorkspaceStaffOnboardingMutationLease lease = @@ -106,13 +120,68 @@ await mutations.AcquireExistingAsync( WorkspaceStaffOnboarding? application = lease.Application; if (application is null) { - return Result.Failure< - WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( + return Failure( WorkspaceStaffOnboardingApplicationErrors .ApplicationNotFound); } + Result + converged = await anchorConvergence.ConvergeAcquiredAsync( + application, + cancellationToken).ConfigureAwait(false); + if (converged.IsFailure) + { + return Failure(converged.Error); + } + + if (converged.Value.Outcome != + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome.Absent) + { + return Result.Success( + WorkspaceStaffOnboardingDataRightsCorrectionOutcome + .AuthorityMovedToStaff()); + } + + if (WorkspaceStaffOnboardingProfileMutationAuthority + .HasLocalIdentityAnchorCoordinates(application)) + { + return Failure( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorConflict); + } + + if (command.ExpectedVersion != application.Version) + { + return Failure( + WorkspaceStaffOnboardingErrors.CorrectionVersionConflict); + } + + if (application.Status != WorkspaceStaffOnboardingState.Submitted) + { + return Failure(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 Failure( + WorkspaceStaffOnboardingApplicationErrors + .CorrectionTargetUnavailable); + } + Result updated = application.ApplyDataRightsCorrection( requested.Value, @@ -121,9 +190,7 @@ await mutations.AcquireExistingAsync( nowUtc); if (updated.IsFailure) { - return Result.Failure< - WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( - updated.Error); + return Failure(updated.Error); } Result created = @@ -143,33 +210,72 @@ await mutations.AcquireExistingAsync( updated.Value.OccurredAtUtc); if (created.IsFailure) { - return Result.Failure< - WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( - created.Error); + return Failure(created.Error); } await receipts.AddAsync(created.Value, cancellationToken) .ConfigureAwait(false); - return Result.Success(created.Value.ToDto()); + return Applied(created.Value); } private static Result< - WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto> Replay( - WorkspaceStaffOnboardingCorrectionReceipt receipt, - ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommand command, - string requestSha256) => + WorkspaceStaffOnboardingDataRightsCorrectionOutcome> Replay( + WorkspaceStaffOnboardingCorrectionReceipt receipt, + ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommand command, + string requestSha256) => receipt.MatchesReplay( command.CaseId, command.ApprovalRevision, command.ApplicationId, command.ExpectedVersion, requestSha256) - ? Result.Success(receipt.ToDto()) + ? Applied(receipt) : Result.Failure< - WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( + WorkspaceStaffOnboardingDataRightsCorrectionOutcome>( WorkspaceStaffOnboardingApplicationErrors .CorrectionIdempotencyConflict); + private static Result + Applied(WorkspaceStaffOnboardingCorrectionReceipt receipt) => + Result.Success( + WorkspaceStaffOnboardingDataRightsCorrectionOutcome.Applied( + receipt.ToDto())); + + private static Result + Failure(Error error) => + Result.Failure( + error); + + private static Result< + WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto> Map( + Result outcome) + { + if (outcome.IsFailure) + { + return Result.Failure< + WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( + outcome.Error); + } + + return outcome.Value.Kind switch + { + WorkspaceStaffOnboardingDataRightsCorrectionOutcomeKind.Applied + when outcome.Value.Receipt is not null => + Result.Success(outcome.Value.Receipt), + WorkspaceStaffOnboardingDataRightsCorrectionOutcomeKind + .AuthorityMovedToStaff + when outcome.Value.Receipt is null => + Result.Failure< + WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( + WorkspaceStaffOnboardingApplicationErrors + .CorrectionTargetUnavailable), + _ => Result.Failure< + WorkspaceStaffOnboardingDataRightsCorrectionReceiptDto>( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorConflict) + }; + } + private static DateTimeOffset ToPersistencePrecision( DateTimeOffset value) { diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQueryHandler.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQueryHandler.cs index 58375ccb..704f5371 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQueryHandler.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQueryHandler.cs @@ -1,7 +1,7 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; +using BunkFy.Modules.Staff.Contracts; using BunkFy.Modules.Workspaces.Application.Authorization; -using BunkFy.Modules.Workspaces.Application.Models; using BunkFy.Modules.Workspaces.Application.Ports; using BunkFy.Modules.Workspaces.Application.Queries; using BunkFy.Modules.Workspaces.Contracts; @@ -11,7 +11,9 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; internal sealed class GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQueryHandler( - IWorkspaceStaffOnboardingDataRightsCorrectionTargetReader targets, + WorkspaceStaffOnboardingMutationCoordinator mutations, + IWorkspaceStaffOnboardingSerializedReadBoundary readBoundary, + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader anchorOutcomes, WorkspaceStaffOnboardingDataRightsCorrectionAuthorizer authorizer) : IQueryHandler< GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQuery, @@ -38,38 +40,53 @@ public async Task< authorized.Error); } - WorkspaceStaffOnboardingDataRightsCorrectionTarget? target = - await targets.GetAsync( - query.ApplicationId, - cancellationToken).ConfigureAwait(false); - if (target is null) + return await readBoundary.RunAsync( + async readToken => { - return Result.Failure< - WorkspaceStaffOnboardingDataRightsCorrectionTargetDto>( - WorkspaceStaffOnboardingApplicationErrors - .ApplicationNotFound); - } + WorkspaceStaffOnboardingMutationLease lease = + await mutations.AcquireExistingAsync( + query.ApplicationId, + WorkspaceStaffOnboardingSourceLockMode.Read, + requireOperational: false, + readToken).ConfigureAwait(false); + WorkspaceStaffOnboarding? application = lease.Application; + if (application is null) + { + return Result.Failure< + WorkspaceStaffOnboardingDataRightsCorrectionTargetDto>( + WorkspaceStaffOnboardingApplicationErrors + .ApplicationNotFound); + } - if (target.Status != WorkspaceStaffOnboardingState.Submitted || - target.Version != query.ExpectedVersion || - string.IsNullOrWhiteSpace(target.DisplayName)) - { - return Result.Failure< - WorkspaceStaffOnboardingDataRightsCorrectionTargetDto>( - WorkspaceStaffOnboardingApplicationErrors - .CorrectionTargetUnavailable); - } + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome = + await anchorOutcomes.ReadAsync( + new StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest( + application.Id, + application.SubjectId), + readToken).ConfigureAwait(false); + if (!WorkspaceStaffOnboardingProfileMutationAuthority + .IsExactAbsent(application, outcome) || + application.Status != WorkspaceStaffOnboardingState.Submitted || + application.Version != query.ExpectedVersion || + string.IsNullOrWhiteSpace(application.DisplayName)) + { + return Result.Failure< + WorkspaceStaffOnboardingDataRightsCorrectionTargetDto>( + WorkspaceStaffOnboardingApplicationErrors + .CorrectionTargetUnavailable); + } - return Result.Success( - new WorkspaceStaffOnboardingDataRightsCorrectionTargetDto( - target.ApplicationId, - target.Version, - target.DisplayName, - target.LegalName, - target.WorkEmail, - target.WorkPhone, - target.EmployeeNumber, - target.JobTitle, - target.Department)); + return Result.Success( + new WorkspaceStaffOnboardingDataRightsCorrectionTargetDto( + application.Id, + application.Version, + application.DisplayName, + application.LegalName, + application.WorkEmail, + application.WorkPhone, + application.EmployeeNumber, + application.JobTitle, + application.Department)); + }, cancellationToken).ConfigureAwait(false); } } 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/PrepareWorkspaceStaffAccessCommandHandler.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/PrepareWorkspaceStaffAccessCommandHandler.cs index 0ef97615..d35813cc 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/PrepareWorkspaceStaffAccessCommandHandler.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/PrepareWorkspaceStaffAccessCommandHandler.cs @@ -2,18 +2,24 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; using BunkFy.Modules.Staff.Contracts; using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Mapping; using BunkFy.Modules.Workspaces.Application.Ports; using BunkFy.Modules.Workspaces.Domain; using Gma.Framework.Cqrs; using Gma.Framework.Results; using Gma.Framework.Runtime.Time; +using Microsoft.Extensions.Logging; +using DomainRestorationDisposition = + BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessRestorationDisposition; internal sealed class PrepareWorkspaceStaffAccessCommandHandler( IWorkspaceStaffAccessProcessRepository processes, + IWorkspaceStaffOnboardingRestorationSuppressionReader suppressions, WorkspaceStaffAccessMutationCoordinator mutations, WorkspaceAccessProvisioner access, WorkspaceOperationalAdmissionEvaluator operationalAdmission, - ISystemClock clock) + ISystemClock clock, + ILogger logger) : ICommandHandler { public async Task> HandleAsync( @@ -81,28 +87,65 @@ await operationalAdmission.EvaluateAsync( } IReadOnlyCollection profileTargets; + DomainRestorationDisposition restorationDisposition; if (targetState == WorkspaceStaffAccessTargetState.Active) { - WorkspaceStaffAccessProcess? suspension = observedPriorCommit ?? - await processes.GetLatestCompletedSuspensionAsync( - context.StaffMemberId, - context.AuthSubjectId, - cancellationToken) - .ConfigureAwait(false); - if (suspension is null) + WorkspaceStaffOnboardingRestorationSuppressionState suppression = + await suppressions.ReadAsync( + context.ScopeId, + context.StaffMemberId, + context.AuthSubjectId, + cancellationToken).ConfigureAwait(false); + if (suppression == + WorkspaceStaffOnboardingRestorationSuppressionState.Conflict) { return Result.Failure( - WorkspaceStaffAccessApplicationErrors.ResumeSnapshotUnavailable); + WorkspaceStaffAccessApplicationErrors.ProcessConflict); + } + + if (suppression == + WorkspaceStaffOnboardingRestorationSuppressionState.Suppressed) + { + restorationDisposition = + DomainRestorationDisposition.Suppressed; + profileTargets = []; + logger.LogInformation( + "Automatic workspace access restoration was suppressed by a terminal Staff onboarding resolution."); } + else if (suppression == + WorkspaceStaffOnboardingRestorationSuppressionState.None) + { + WorkspaceStaffAccessProcess? suspension = observedPriorCommit ?? + await processes.GetLatestCompletedSuspensionAsync( + context.StaffMemberId, + context.AuthSubjectId, + cancellationToken) + .ConfigureAwait(false); + if (suspension is null) + { + return Result.Failure( + WorkspaceStaffAccessApplicationErrors + .ResumeSnapshotUnavailable); + } - profileTargets = suspension.ProfileSnapshots - .Select(snapshot => new WorkspaceStaffAccessProfileTarget( - snapshot.ProfileId, - snapshot.AssignmentScope)) - .ToArray(); + restorationDisposition = + DomainRestorationDisposition.RestoreSnapshot; + profileTargets = suspension.ProfileSnapshots + .Select(snapshot => new WorkspaceStaffAccessProfileTarget( + snapshot.ProfileId, + snapshot.AssignmentScope)) + .ToArray(); + } + else + { + return Result.Failure( + WorkspaceStaffAccessApplicationErrors.ProcessConflict); + } } else { + restorationDisposition = + DomainRestorationDisposition.NotApplicable; profileTargets = await access.CaptureRestorableProfilesAsync( context.ScopeId, context.AuthSubjectId, @@ -115,6 +158,7 @@ await processes.GetLatestCompletedSuspensionAsync( context.StaffMemberId, context.AuthSubjectId, targetState, + restorationDisposition, context.TargetVersion, context.EffectiveOn, context.ActorId, @@ -139,7 +183,11 @@ await processes.GetLatestCompletedSuspensionAsync( } private static WorkspaceStaffAccessPreparation ToPreparation(WorkspaceStaffAccessProcess process) => - new(process.Id, process.State == WorkspaceStaffAccessProcessState.Prepared); + new( + process.Id, + process.State == WorkspaceStaffAccessProcessState.Prepared, + WorkspaceStaffAccessMappings.MapRestorationDisposition( + process.RestorationDisposition)); private static WorkspaceStaffAccessTargetState ToTargetState(StaffStatus status) => status switch { 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/ReviewWorkspaceStaffHistoricalNoProvisionCommandHandler.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ReviewWorkspaceStaffHistoricalNoProvisionCommandHandler.cs new file mode 100644 index 00000000..12f67bca --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ReviewWorkspaceStaffHistoricalNoProvisionCommandHandler.cs @@ -0,0 +1,393 @@ +namespace BunkFy.Modules.Workspaces.Application.Handlers; + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; +using Gma.Framework.Scoping; + +internal sealed class + ReviewWorkspaceStaffHistoricalNoProvisionCommandHandler( + IWorkspaceCrossGraphMutationLock crossGraphLock, + WorkspaceStaffOnboardingMutationCoordinator mutations, + IWorkspaceStaffHistoricalNoProvisionReceiptRepository receipts, + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader outcomes, + IStaffIdentityProvisioningAnchorCutover cutover, + WorkspaceStaffHistoricalNoProvisionAuthorityReader authorities, + IScopeContext scopeContext, + ISystemClock clock, + IIdGenerator ids) + : ICommandHandler< + ReviewWorkspaceStaffHistoricalNoProvisionCommand, + WorkspaceStaffHistoricalNoProvisionDispositionResult> +{ + public async Task< + Result> + HandleAsync( + ReviewWorkspaceStaffHistoricalNoProvisionCommand command, + CancellationToken cancellationToken) + { + if (!WorkspaceStaffIdentityAnchorTenantScope.TryGetCanonicalTenantId( + scopeContext, + out string tenantId)) + { + return Failure( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .TenantRequired); + } + + string externalDigest = NormalizeSha256( + command.ExternalEvidenceSha256); + string reviewerId = command.ReviewerId?.Trim() ?? string.Empty; + if (command.OperationId == Guid.Empty || + command.ApplicationId == Guid.Empty || + command.ExpectedApplicationVersion is < 1 or long.MaxValue || + !Enum.IsDefined(command.ExpectedApplicationStatus) || + command.ExpectedApplicationStatus == + WorkspaceStaffOnboardingState.Unknown || + command.ExpectedOrganizationsScopeRevision < 0 || + command.ExpectedOrganizationsSourceVersion < 1 || + !Enum.IsDefined(command.ExpectedOrganizationsSourceStatus) || + command.ExpectedOrganizationsSourceStatus == + WorkspaceStaffHistoricalNoProvisionAuthorityStatus.Unknown || + command.ExternalEvidenceManifestId == Guid.Empty || + !IsSha256(externalDigest) || + reviewerId.Length is 0 or > + WorkspaceStaffHistoricalNoProvisionReceipt + .ReviewerIdMaxLength || + reviewerId.Any(char.IsControl)) + { + return Failure( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .RequestInvalid); + } + + await crossGraphLock.AcquireAsync(cancellationToken) + .ConfigureAwait(false); + WorkspaceStaffOnboardingMutationLease lease = + await mutations.AcquireExistingAsync( + command.ApplicationId, + WorkspaceStaffOnboardingSourceLockMode.Write, + requireOperational: false, + cancellationToken) + .ConfigureAwait(false); + WorkspaceStaffOnboarding? application = lease.Application; + WorkspaceStaffHistoricalNoProvisionReceipt? replay = await receipts + .FindByOperationIdAsync( + command.OperationId, + cancellationToken) + .ConfigureAwait(false); + if (replay is not null) + { + return application is not null && + replay.MatchesReplay( + tenantId, + command.ApplicationId, + command.ExpectedApplicationVersion, + command.ExpectedApplicationStatus, + command.ExpectedOrganizationsScopeRevision, + command.ExpectedOrganizationsSourceVersion, + command.ExpectedOrganizationsSourceStatus, + command.ExternalEvidenceManifestId, + externalDigest, + reviewerId) && + replay.MatchesResult(application) + ? Result.Success(Map(replay, alreadyReviewed: true)) + : Failure( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .Conflict); + } + + if (application is null || + !string.Equals( + application.ScopeId, + tenantId, + StringComparison.Ordinal) || + application.Id != command.ApplicationId || + application.Version != command.ExpectedApplicationVersion || + application.Status != command.ExpectedApplicationStatus || + application.HasIdentityAnchorState) + { + return Failure( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .Conflict); + } + + WorkspaceStaffHistoricalNoProvisionReceipt? prior = await receipts + .FindByApplicationIdAsync( + command.ApplicationId, + cancellationToken) + .ConfigureAwait(false); + if (prior is not null) + { + return Failure( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .Conflict); + } + + Result staffEvidence = await this.ReadStaffEvidenceAsync( + application, + cancellationToken) + .ConfigureAwait(false); + if (staffEvidence.IsFailure) + { + return Failure(staffEvidence.Error); + } + + if (!Guid.TryParseExact(tenantId, "D", out Guid organizationId)) + { + return Failure( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .TenantRequired); + } + + Guid receiptId = ids.NewId(); + if (receiptId == Guid.Empty) + { + return Failure( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .RequestInvalid); + } + + DateTimeOffset nowUtc = clock.UtcNow; + DateTimeOffset reviewedAtUtc = ToPersistencePrecision( + nowUtc < application.LastChangedAtUtc + ? application.LastChangedAtUtc + : nowUtc); + Result authority; + try + { + authority = await authorities.ReadAsync( + organizationId, + application.SourceKind, + application.SourceId, + command.ExpectedOrganizationsScopeRevision, + cancellationToken) + .ConfigureAwait(false); + } + catch (Exception exception) + when (exception is not OperationCanceledException) + { + return Failure( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .ExternalEvidenceUnavailable); + } + + if (authority.IsFailure || + authority.Value.SourceId != application.SourceId || + authority.Value.SourceVersion != + command.ExpectedOrganizationsSourceVersion || + authority.Value.SourceStatus != + command.ExpectedOrganizationsSourceStatus || + !WorkspaceStaffHistoricalNoProvisionReceipt.IsTerminalAuthority( + application.SourceKind, + authority.Value.SourceStatus)) + { + return Failure( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .Conflict); + } + + Result reviewed = application.ReviewHistoricalNoProvision( + receiptId, + reviewedAtUtc); + if (reviewed.IsFailure) + { + return Failure( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .Conflict); + } + + Result created = + WorkspaceStaffHistoricalNoProvisionReceipt.Create( + receiptId, + tenantId, + command.OperationId, + application.Id, + application.SourceKind, + application.SourceId, + command.ExpectedApplicationVersion, + command.ExpectedApplicationStatus, + application.Version, + application.Status, + command.ExpectedOrganizationsScopeRevision, + authority.Value.SourceVersion, + authority.Value.SourceStatus, + staffEvidence.Value, + command.ExternalEvidenceManifestId, + externalDigest, + reviewerId, + reviewedAtUtc); + if (created.IsFailure) + { + return Failure( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .Conflict); + } + + await receipts.AddAsync(created.Value, cancellationToken) + .ConfigureAwait(false); + return Result.Success(Map(created.Value, alreadyReviewed: false)); + } + + private async Task> ReadStaffEvidenceAsync( + WorkspaceStaffOnboarding application, + CancellationToken cancellationToken) + { + IReadOnlyList + outcomeBatch; + StaffIdentityProvisioningAnchorInspection inspection; + try + { + outcomeBatch = await outcomes.ReadAsync( + [new StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest( + application.Id, + application.SubjectId)], + cancellationToken) + .ConfigureAwait(false); + inspection = await cutover.InspectAsync( + [new StaffIdentityProvisioningAnchorCandidate( + StaffIdentityProvisioningAnchorSourceKind + .WorkspaceOnboarding, + application.Id, + StaffMemberId: null, + application.SubjectId)], + cancellationToken) + .ConfigureAwait(false); + } + catch (Exception exception) + when (exception is not OperationCanceledException) + { + return Result.Failure( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .ExternalEvidenceUnavailable); + } + + StaffWorkspaceOnboardingIdentityAnchorOutcome? outcome = + outcomeBatch is { Count: 1 } + ? outcomeBatch[0] + : null; + bool exactAbsent = outcome is not null && + outcome.ApplicationId == application.Id && + outcome.Status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Absent && + outcome.StaffMemberId is null && + outcome.TargetLifecycle == + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Unknown && + outcome.SubjectMatch == + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Unknown && + outcome.WorkspaceApplicationVersion is null && + outcome.ResolutionDisposition is null && + outcome.ResolutionEventId is null; + StaffIdentityProvisioningAnchorCandidateInspection? item = + inspection?.Items is { Count: 1 } + ? inspection.Items[0] + : null; + bool exactAmbiguous = inspection is not null && + inspection.IsSuccess && + string.IsNullOrWhiteSpace(inspection.ErrorCode) && + item is not null && + item.SourceKind == + StaffIdentityProvisioningAnchorSourceKind.WorkspaceOnboarding && + item.SourceId == application.Id && + item.Disposition == + StaffIdentityProvisioningAnchorCutoverDisposition.Ambiguous; + return exactAbsent && exactAmbiguous + ? Result.Success(ComputeStaffEvidenceSha256(application.Id)) + : Result.Failure( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .Conflict); + } + + private static string ComputeStaffEvidenceSha256(Guid applicationId) + { + StringBuilder canonical = new(); + Append( + canonical, + "workspaces-staff-historical-no-provision-staff-evidence|v1"); + Append(canonical, applicationId); + Append( + canonical, + (int)StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Absent); + Append(canonical, "null-target"); + Append( + canonical, + (int)StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Unknown); + Append( + canonical, + (int)StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Unknown); + Append(canonical, "null-resolution"); + Append( + canonical, + (int)StaffIdentityProvisioningAnchorSourceKind.WorkspaceOnboarding); + Append( + canonical, + (int)StaffIdentityProvisioningAnchorCutoverDisposition.Ambiguous); + return Convert.ToHexString( + SHA256.HashData( + Encoding.UTF8.GetBytes(canonical.ToString()))) + .ToLowerInvariant(); + } + + private static WorkspaceStaffHistoricalNoProvisionDispositionResult Map( + WorkspaceStaffHistoricalNoProvisionReceipt receipt, + bool alreadyReviewed) => + new( + receipt.Id, + receipt.OperationId, + receipt.ApplicationId, + receipt.ResultApplicationVersion, + receipt.ResultApplicationStatus, + receipt.StaffEvidenceSha256, + receipt.CanonicalSha256, + receipt.ReviewedAtUtc, + alreadyReviewed); + + private static Result< + WorkspaceStaffHistoricalNoProvisionDispositionResult> Failure( + Error error) => + Result.Failure( + error); + + private static string NormalizeSha256(string? value) => + value?.Trim().ToLowerInvariant() ?? string.Empty; + + private static bool IsSha256(string value) => + value.Length == WorkspaceStaffHistoricalNoProvisionReceipt + .Sha256Length && + value.All(character => character is (>= '0' and <= '9') or + (>= 'a' and <= 'f')); + + 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); + } + + private static void Append(StringBuilder target, object value) + { + string text = value switch + { + Guid id => id.ToString("N"), + IFormattable formattable => formattable.ToString( + null, + CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty + }; + target.Append(text.Length.ToString(CultureInfo.InvariantCulture)); + target.Append(':'); + target.Append(text); + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ScrubWorkspaceStaffRetentionCorrelationCommandHandler.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ScrubWorkspaceStaffRetentionCorrelationCommandHandler.cs index 8ea6100e..f12c38c1 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ScrubWorkspaceStaffRetentionCorrelationCommandHandler.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/ScrubWorkspaceStaffRetentionCorrelationCommandHandler.cs @@ -13,6 +13,8 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; internal sealed class ScrubWorkspaceStaffRetentionCorrelationCommandHandler( IWorkspaceStaffRetentionCorrelationRepository repository, + IWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence + identityAnchors, IWorkspaceCrossGraphMutationLock crossGraphLock, WorkspaceStaffAccessMutationCoordinator mutations, IWorkspaceStaffRetentionAccessClosure accessClosure, @@ -71,6 +73,17 @@ await accessClosure.EnsureClosedAsync( : "The workspace Staff retention prerequisite is temporarily unavailable.")); } + if (!await identityAnchors.CanMutateAsync( + tenantId, + closure.SubjectId, + cancellationToken).ConfigureAwait(false)) + { + return Result.Failure< + WorkspaceStaffRetentionCorrelationReceipt>( + WorkspaceStaffRetentionErrors + .IdentityAnchorUnavailable); + } + return await repository.ScrubAsync( new WorkspaceStaffRetentionCorrelationScrubRequest( ids.NewId(), diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/StaffIdentityProvisioningAnchorCreatedWorkspacesHandler.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/StaffIdentityProvisioningAnchorCreatedWorkspacesHandler.cs new file mode 100644 index 00000000..7b533549 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/StaffIdentityProvisioningAnchorCreatedWorkspacesHandler.cs @@ -0,0 +1,58 @@ +namespace BunkFy.Modules.Workspaces.Application.Handlers; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Contracts; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Messaging; +using Gma.Framework.Results; + +[IntegrationEventHandler( + WorkspacesModuleMetadata.StaffOnboardingIdentityAnchorCreatedHandlerName)] +internal sealed class StaffIdentityProvisioningAnchorCreatedWorkspacesHandler( + IWorkspaceStaffOnboardingRepository applications, + WorkspaceStaffOnboardingProcessor processor) + : IIntegrationEventHandler< + StaffIdentityProvisioningAnchorCreatedIntegrationEvent> +{ + public async Task HandleAsync( + StaffIdentityProvisioningAnchorCreatedIntegrationEvent integrationEvent, + CancellationToken cancellationToken) + { + if (integrationEvent.EventId != integrationEvent.ApplicationId) + { + throw new InvalidOperationException( + "The Staff identity-anchor event coordinate is invalid."); + } + if (integrationEvent.ResolutionEventId == Guid.Empty || + integrationEvent.ResolutionEventId == integrationEvent.ApplicationId) + { + throw new InvalidOperationException( + "The Staff identity-anchor resolution coordinate is invalid."); + } + + WorkspaceStaffOnboarding? application = await applications.GetAsync( + integrationEvent.ApplicationId, + cancellationToken).ConfigureAwait(false); + if (application is null || + !string.Equals( + application.ScopeId, + integrationEvent.ScopeId, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The Staff identity anchor has no Workspaces onboarding application."); + } + + Result processed = await processor.ProcessAnchorCreatedAsync( + application, + integrationEvent.StaffMemberId, + integrationEvent.ResolutionEventId, + cancellationToken).ConfigureAwait(false); + if (processed.IsFailure) + { + throw new InvalidOperationException( + $"Staff identity-anchor convergence failed with '{processed.Error.Code}'."); + } + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/StaffLifecycleWorkspaceAccessHandler.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/StaffLifecycleWorkspaceAccessHandler.cs index dd15ef23..731262dc 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/StaffLifecycleWorkspaceAccessHandler.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/StaffLifecycleWorkspaceAccessHandler.cs @@ -6,6 +6,8 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; using BunkFy.Modules.Workspaces.Domain; using Gma.Framework.Messaging; using Gma.Framework.Runtime.Time; +using DomainRestorationDisposition = + BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessRestorationDisposition; [IntegrationEventHandler(HandlerName, RequiresExplicitProducerBinding = true)] internal sealed class StaffLifecycleWorkspaceAccessHandler( @@ -39,10 +41,20 @@ public async Task HandleAsync( throw new InvalidOperationException("The Staff lifecycle process could not observe the Staff commit."); } - if (process.TargetState == WorkspaceStaffAccessTargetState.Active) + if (process.TargetState == WorkspaceStaffAccessTargetState.Active && + process.RestorationDisposition == + DomainRestorationDisposition.RestoreSnapshot) { await restorer.RestoreAsync(process, cancellationToken).ConfigureAwait(false); } + else if (process.TargetState == WorkspaceStaffAccessTargetState.Active && + (process.RestorationDisposition != + DomainRestorationDisposition.Suppressed || + process.State != WorkspaceStaffAccessProcessState.Completed)) + { + throw new InvalidOperationException( + "The Staff lifecycle event has an invalid access-restoration disposition."); + } } private static WorkspaceStaffAccessTargetState ToTargetState(StaffStatus status) => status switch 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..95e52a9c 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( @@ -19,17 +20,37 @@ internal sealed class SubmitWorkspaceStaffOnboardingCommandHandler( IWorkspaceStaffOnboardingProcessingRestrictionProjectionRepository restrictionProjections, WorkspaceStaffOnboardingMutationCoordinator mutations, + WorkspaceStaffOnboardingIdentityAnchorConvergence anchorConvergence, IWorkspaceStaffAccessPlanRepository plans, WorkspaceStaffJoinTokenAuthorityResolver authorityResolver, IAuthMemberAdmissionReader admissions, + IOrganizationEnrollmentClaimInspector claims, IOptions options, WorkspaceOperationalAdmissionEvaluator operationalAdmission, IScopeContext scopeContext, ISystemClock clock, IIdGenerator ids) - : ICommandHandler + : ICommandHandler< + SubmitWorkspaceStaffOnboardingCommand, + WorkspaceStaffOnboardingSubmissionOutcome> { public async Task> HandleAsync( + SubmitWorkspaceStaffOnboardingCommand command, + CancellationToken cancellationToken) => + WorkspaceStaffOnboardingSubmitter.Map( + await this.HandleWithAuthorityOutcomeAsync( + command, + cancellationToken).ConfigureAwait(false)); + + Task> ICommandHandler< + SubmitWorkspaceStaffOnboardingCommand, + WorkspaceStaffOnboardingSubmissionOutcome>.HandleAsync( + SubmitWorkspaceStaffOnboardingCommand command, + CancellationToken cancellationToken) => + this.HandleWithAuthorityOutcomeAsync(command, cancellationToken); + + internal async Task> + HandleWithAuthorityOutcomeAsync( SubmitWorkspaceStaffOnboardingCommand command, CancellationToken cancellationToken) { @@ -39,7 +60,7 @@ public async Task> HandleAsync( cancellationToken).ConfigureAwait(false); if (!authority.HasValue) { - return Result.Failure( + return Failure( WorkspaceStaffOnboardingApplicationErrors.JoinTokenInvalid); } @@ -48,7 +69,7 @@ public async Task> HandleAsync( authority.Value.OrganizationId.ToString("D"), StringComparison.Ordinal)) { - return Result.Failure( + return Failure( WorkspaceStaffOnboardingApplicationErrors.AccessPlanUnavailable); } @@ -58,13 +79,13 @@ await operationalAdmission.EvaluateAsync( cancellationToken).ConfigureAwait(false)); if (admitted.IsFailure) { - return Result.Failure(admitted.Error); + return Failure(admitted.Error); } WorkspaceStaffOnboardingSource sourceKind = command.SourceKind.ToDomain(); if (!Guid.TryParse(command.SubjectId, out Guid memberId)) { - return Result.Failure( + return Failure( WorkspaceStaffOnboardingApplicationErrors.VerifiedIdentityRequired); } @@ -79,11 +100,39 @@ await mutations.AcquireApplicantAsync( WorkspaceStaffOnboarding? application = lease.Application; if (lease.CoordinateExists && application is null) { - return Result.Failure( + return Failure( WorkspaceStaffOnboardingApplicationErrors .ProcessingRestricted); } + if (application is not null) + { + Result + converged = await anchorConvergence.ConvergeAcquiredAsync( + application, + cancellationToken).ConfigureAwait(false); + if (converged.IsFailure) + { + return Failure(converged.Error); + } + + if (converged.Value.Outcome != + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome.Absent) + { + return Result.Success( + WorkspaceStaffOnboardingSubmissionOutcome + .AuthorityMovedToStaff()); + } + + if (WorkspaceStaffOnboardingProfileMutationAuthority + .HasLocalIdentityAnchorCoordinates(application)) + { + return Failure( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorConflict); + } + } + AuthMemberAdmission? admission = await admissions.FindActiveAsync( options.Value.GlobalAuthScopeId, memberId, @@ -92,7 +141,7 @@ await mutations.AcquireApplicantAsync( string? verifiedEmail = admission?.PreferredVerifiedEmail; if (string.IsNullOrWhiteSpace(verifiedEmail)) { - return Result.Failure( + return Failure( WorkspaceStaffOnboardingApplicationErrors.VerifiedIdentityRequired); } @@ -100,12 +149,12 @@ await mutations.AcquireApplicantAsync( (!authority.Value.AllowsSubmissionMutation || application.Status != WorkspaceStaffOnboardingState.Submitted)) { - return Result.Success(application.ToDto()); + return Applied(application); } if (application is null && !authority.Value.AllowsSubmissionMutation) { - return Result.Failure( + return Failure( WorkspaceStaffOnboardingApplicationErrors.JoinTokenInvalid); } @@ -115,11 +164,26 @@ await mutations.AcquireApplicantAsync( if (plan is null || plan.SourceKind != sourceKind || plan.Status != WorkspaceStaffAccessPlanState.Active) { - return Result.Failure( + return Failure( WorkspaceStaffOnboardingApplicationErrors.AccessPlanUnavailable); } DateTimeOffset nowUtc = clock.UtcNow; + if (await WorkspaceStaffOnboardingProfileMutationAuthority + .IsFencedAsync( + claims, + sourceKind, + authority.Value.OrganizationId, + authority.Value.SourceId, + memberId.ToString("D"), + nowUtc, + cancellationToken).ConfigureAwait(false)) + { + return Failure( + WorkspaceStaffOnboardingApplicationErrors + .ProfileMutationAuthorityUnavailable); + } + if (application is null) { Result created = WorkspaceStaffOnboarding.Create( @@ -139,7 +203,7 @@ await mutations.AcquireApplicantAsync( nowUtc); if (created.IsFailure) { - return Result.Failure(created.Error); + return Failure(created.Error); } application = created.Value; @@ -156,8 +220,7 @@ await mutations.AcquireApplicantAsync( nowUtc); if (baseline.IsFailure) { - return Result.Failure( - baseline.Error); + return Failure(baseline.Error); } await restrictionProjections.AddAsync( @@ -178,10 +241,20 @@ await restrictionProjections.AddAsync( nowUtc); if (updated.IsFailure) { - return Result.Failure(updated.Error); + return Failure(updated.Error); } } - return Result.Success(application.ToDto()); + return Applied(application); } + + private static Result Applied( + WorkspaceStaffOnboarding application) => + Result.Success( + WorkspaceStaffOnboardingSubmissionOutcome.Applied( + application.ToDto())); + + private static Result Failure( + Error error) => + Result.Failure(error); } diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffAccessRestorer.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffAccessRestorer.cs index 2a50b773..1b403847 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffAccessRestorer.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffAccessRestorer.cs @@ -6,6 +6,8 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; using Gma.Framework.Runtime.Time; using Gma.Modules.Organizations.Contracts; using Microsoft.Extensions.Logging; +using DomainRestorationDisposition = + BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessRestorationDisposition; internal sealed class WorkspaceStaffAccessRestorer( IOrganizationMembershipLifecycle memberships, @@ -24,7 +26,9 @@ public async Task RestoreAsync( } if (process.State != WorkspaceStaffAccessProcessState.RestorationPending || - process.TargetState != WorkspaceStaffAccessTargetState.Active) + process.TargetState != WorkspaceStaffAccessTargetState.Active || + process.RestorationDisposition != + DomainRestorationDisposition.RestoreSnapshot) { return WorkspaceStaffAccessCoordinationOutcome.RetryRequired; } diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffHistoricalNoProvisionAuthorityReader.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffHistoricalNoProvisionAuthorityReader.cs new file mode 100644 index 00000000..2175d05c --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffHistoricalNoProvisionAuthorityReader.cs @@ -0,0 +1,220 @@ +namespace BunkFy.Modules.Workspaces.Application.Handlers; + +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Results; +using Gma.Modules.Organizations.Contracts; + +internal sealed class WorkspaceStaffHistoricalNoProvisionAuthorityReader( + IOrganizationScopeLifecycle organizations) +{ + public async Task> + ReadAsync( + Guid organizationId, + WorkspaceStaffOnboardingSource sourceKind, + Guid sourceId, + long expectedScopeRevision, + CancellationToken cancellationToken) + { + if (organizationId == Guid.Empty || + sourceId == Guid.Empty || + expectedScopeRevision < 0 || + sourceKind is not (WorkspaceStaffOnboardingSource.Invitation or + WorkspaceStaffOnboardingSource.EnrollmentLink)) + { + return Conflict(); + } + + OrganizationScopeSnapshot snapshot = await organizations + .GetSnapshotAsync(organizationId, cancellationToken) + .ConfigureAwait(false); + if (snapshot.Status != OrganizationScopeStatus.Open || + snapshot.Revision != expectedScopeRevision) + { + return Conflict(); + } + + OrganizationScopeExportStore store = sourceKind == + WorkspaceStaffOnboardingSource.Invitation + ? OrganizationScopeExportStore.Invitations + : OrganizationScopeExportStore.EnrollmentLinks; + string? afterCursor = null; + Guid? previousId = null; + WorkspaceStaffHistoricalNoProvisionAuthority? found = null; + int matchCount = 0; + while (true) + { + OrganizationScopeExportPage? page = await organizations.ExportAsync( + new OrganizationScopeExportRequest( + organizationId, + expectedScopeRevision, + store, + afterCursor, + OrganizationScopeLifecycleLimits.MaximumPageSize), + cancellationToken) + .ConfigureAwait(false); + if (!IsValidPage( + page, + store, + expectedScopeRevision)) + { + return Conflict(); + } + + foreach (OrganizationScopeExportRecord record in page.Records) + { + Result mapped = + Map(record, sourceKind, organizationId); + if (mapped.IsFailure || + (previousId.HasValue && + mapped.Value.SourceId.CompareTo(previousId.Value) <= 0)) + { + return Conflict(); + } + + previousId = mapped.Value.SourceId; + if (mapped.Value.SourceId == sourceId) + { + matchCount++; + found = mapped.Value; + } + } + + string? expectedCursor = previousId.HasValue + ? "id:" + previousId.Value.ToString("D") + : afterCursor; + if (!string.Equals( + page.NextCursor, + expectedCursor, + StringComparison.Ordinal) || + (page.HasMore && + (page.Records.Count != + OrganizationScopeLifecycleLimits.MaximumPageSize || + string.IsNullOrWhiteSpace(page.NextCursor) || + string.Equals( + page.NextCursor, + afterCursor, + StringComparison.Ordinal)))) + { + return Conflict(); + } + + if (!page.HasMore) + { + break; + } + + afterCursor = page.NextCursor; + } + + OrganizationScopeSnapshot finalSnapshot = await organizations + .GetSnapshotAsync(organizationId, cancellationToken) + .ConfigureAwait(false); + if (finalSnapshot.Status != OrganizationScopeStatus.Open || + finalSnapshot.Revision != expectedScopeRevision || + matchCount != 1 || + found is null) + { + return Conflict(); + } + + return Result.Success(found); + } + + private static bool IsValidPage( + OrganizationScopeExportPage? page, + OrganizationScopeExportStore store, + long expectedScopeRevision) => + page is not null && + page.Status == OrganizationScopeExportStatus.Completed && + page.ScopeRevision == expectedScopeRevision && + page.Store == store && + page.Records is not null && + page.Records.Count <= + OrganizationScopeLifecycleLimits.MaximumPageSize; + + private static Result Map( + OrganizationScopeExportRecord record, + WorkspaceStaffOnboardingSource sourceKind, + Guid organizationId) + { + if (sourceKind == WorkspaceStaffOnboardingSource.Invitation && + record is OrganizationScopeInvitationExportRecord invitation && + invitation.InvitationId != Guid.Empty && + invitation.OrganizationId == organizationId && + invitation.Version >= 1 && + Enum.IsDefined(invitation.Status) && + invitation.Status != OrganizationInvitationStatus.Unknown) + { + return Result.Success( + new WorkspaceStaffHistoricalNoProvisionAuthority( + invitation.InvitationId, + invitation.Version, + invitation.Status switch + { + OrganizationInvitationStatus.Pending => + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationPending, + OrganizationInvitationStatus.Accepted => + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationAccepted, + OrganizationInvitationStatus.Revoked => + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationRevoked, + OrganizationInvitationStatus.Superseded => + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationSuperseded, + OrganizationInvitationStatus.Expired => + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationExpired, + _ => WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .Unknown + })); + } + + if (sourceKind == WorkspaceStaffOnboardingSource.EnrollmentLink && + record is OrganizationScopeEnrollmentLinkExportRecord link && + link.EnrollmentLinkId != Guid.Empty && + link.OrganizationId == organizationId && + link.Version >= 1 && + Enum.IsDefined(link.Status) && + link.Status != OrganizationEnrollmentLinkStatus.Unknown) + { + return Result.Success( + new WorkspaceStaffHistoricalNoProvisionAuthority( + link.EnrollmentLinkId, + link.Version, + link.Status switch + { + OrganizationEnrollmentLinkStatus.Active => + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .EnrollmentLinkActive, + OrganizationEnrollmentLinkStatus.Disabled => + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .EnrollmentLinkDisabled, + OrganizationEnrollmentLinkStatus.Rotated => + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .EnrollmentLinkRotated, + OrganizationEnrollmentLinkStatus.Expired => + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .EnrollmentLinkExpired, + OrganizationEnrollmentLinkStatus.CapacityReached => + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .EnrollmentLinkCapacityReached, + _ => WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .Unknown + })); + } + + return Conflict(); + } + + private static Result + Conflict() => + Result.Failure( + WorkspaceStaffHistoricalNoProvisionApplicationErrors.Conflict); +} + +internal sealed record WorkspaceStaffHistoricalNoProvisionAuthority( + Guid SourceId, + long SourceVersion, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus SourceStatus); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffIdentityAnchorCutoverCoordinator.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffIdentityAnchorCutoverCoordinator.cs new file mode 100644 index 00000000..dd3c960d --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffIdentityAnchorCutoverCoordinator.cs @@ -0,0 +1,1139 @@ +namespace BunkFy.Modules.Workspaces.Application.Handlers; + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application.Ports; +using Gma.Framework.Results; +using Gma.Modules.Organizations.Contracts; + +internal sealed class WorkspaceStaffIdentityAnchorCutoverCoordinator( + IWorkspaceStaffIdentityAnchorCutoverSourceReader sources, + IStaffIdentityProvisioningAnchorCutover staff, + IOrganizationScopeLifecycle organizationScopes) +{ + public async Task> + GetStatusAsync( + string tenantId, + WorkspaceStaffIdentityAnchorOwnerManifest? ownerManifest, + CancellationToken cancellationToken) + { + Result plan = + await this.BuildPlanAsync( + tenantId, + ownerManifest, + requireOwnerManifest: false, + retainedBatchSize: 0, + cancellationToken).ConfigureAwait(false); + return plan.IsSuccess + ? Result.Success(plan.Value.Status) + : Result.Failure( + plan.Error); + } + + public async Task> + PrepareReconcileAsync( + string tenantId, + string expectedSourceEvidenceSha256, + string expectedAnchorStateSha256, + WorkspaceStaffIdentityAnchorOwnerManifest ownerManifest, + string expectedOwnerManifestSha256, + int batchSize, + CancellationToken cancellationToken) + { + if (!IsSha256(expectedSourceEvidenceSha256) || + !IsSha256(expectedAnchorStateSha256) || + !IsSha256(expectedOwnerManifestSha256) || + batchSize is < 1 or + > StaffWorkspaceOnboardingAnchorCutoverLimits.MaximumBatchSize) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorPreparedReconcile>( + WorkspaceStaffIdentityAnchorCutoverErrors.RequestInvalid); + } + + Result plan = + await this.BuildPlanAsync( + tenantId, + ownerManifest, + requireOwnerManifest: true, + batchSize, + cancellationToken).ConfigureAwait(false); + if (plan.IsFailure) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorPreparedReconcile>(plan.Error); + } + + if (!string.Equals( + expectedSourceEvidenceSha256, + plan.Value.Status.SourceEvidenceSha256, + StringComparison.OrdinalIgnoreCase)) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorPreparedReconcile>( + WorkspaceStaffIdentityAnchorCutoverErrors + .SourceEvidenceChanged); + } + + if (!string.Equals( + expectedOwnerManifestSha256, + plan.Value.Status.OwnerManifestSha256, + StringComparison.OrdinalIgnoreCase)) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorPreparedReconcile>( + WorkspaceStaffIdentityAnchorCutoverErrors + .OwnerManifestChanged); + } + + if (!string.Equals( + expectedAnchorStateSha256, + plan.Value.Status.AnchorStateSha256, + StringComparison.OrdinalIgnoreCase)) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorPreparedReconcile>( + WorkspaceStaffIdentityAnchorCutoverErrors + .AnchorStateChanged); + } + + if (!plan.Value.Status.CanReconcile) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorPreparedReconcile>( + WorkspaceStaffIdentityAnchorCutoverErrors.Blocked); + } + + return Result.Success(new WorkspaceStaffIdentityAnchorPreparedReconcile( + Guid.ParseExact(tenantId, "D"), + plan.Value.Batch, + plan.Value.Status, + ownerManifest, + expectedSourceEvidenceSha256, + expectedAnchorStateSha256, + expectedOwnerManifestSha256)); + } + + private async Task> BuildPlanAsync( + string tenantId, + WorkspaceStaffIdentityAnchorOwnerManifest? ownerManifest, + bool requireOwnerManifest, + int retainedBatchSize, + CancellationToken cancellationToken) + { + if (!Guid.TryParseExact(tenantId, "D", out Guid organizationId) || + !string.Equals( + tenantId, + organizationId.ToString("D"), + StringComparison.Ordinal)) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors.TenantRequired); + } + + if (requireOwnerManifest && ownerManifest is null) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .OwnerManifestRequired); + } + + string? ownerManifestSha256 = null; + WorkspaceStaffIdentityAnchorOwnerBinding[] ownerBindings = []; + if (ownerManifest is not null) + { + Result validated = + ValidateOwnerManifest(tenantId, ownerManifest); + if (validated.IsFailure) + { + return Result.Failure( + validated.Error); + } + + ownerBindings = validated.Value; + ownerManifestSha256 = ComputeOwnerManifestSha256( + ownerManifest, + ownerBindings); + } + + Result organizationHeaderResult = + await this.LoadOrganizationHeaderAsync( + organizationId, + cancellationToken).ConfigureAwait(false); + if (organizationHeaderResult.IsFailure) + { + return Result.Failure( + organizationHeaderResult.Error); + } + + AuthoritativeOrganizationHeader organizationHeader = + organizationHeaderResult.Value; + using WorkspaceStaffIdentityAnchorPlanAccumulator accumulator = new( + tenantId, + organizationHeader.ScopeRevision, + organizationHeader.OrganizationVersion, + retainedBatchSize); + + Result workspacesResult = await this.StreamWorkspaceSourcesAsync( + accumulator, + cancellationToken).ConfigureAwait(false); + if (workspacesResult.IsFailure) + { + return Result.Failure( + workspacesResult.Error); + } + + Result organizationsResult = + await this.StreamOrganizationMembershipsAsync( + organizationId, + organizationHeader, + ownerBindings, + accumulator, + cancellationToken).ConfigureAwait(false); + if (organizationsResult.IsFailure) + { + return Result.Failure( + organizationsResult.Error); + } + + bool ownerProvided = ownerManifest is not null; + WorkspaceStaffIdentityAnchorCutoverStatus status = + accumulator.CompleteStatus( + ownerBindings.LongLength, + ownerBindings.LongCount(binding => binding.EvidenceKind == + WorkspaceStaffIdentityAnchorOwnerBindingEvidenceKind + .HistoricalOwnerExternalReview), + ownerManifestSha256, + ownerProvided, + ownerManifest?.HistoricalEvidence.Kind ?? + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + .Unknown, + ownerManifest?.HistoricalEvidence.EvidenceSha256); + return Result.Success(new WorkspaceStaffIdentityAnchorPlan( + status, + accumulator.GetRetainedBatch())); + } + + private async Task StreamWorkspaceSourcesAsync( + WorkspaceStaffIdentityAnchorPlanAccumulator accumulator, + CancellationToken cancellationToken) + { + Guid? afterApplicationId = null; + while (true) + { + WorkspaceStaffIdentityAnchorSourcePage? page = await sources + .ListRelevantPageAsync( + afterApplicationId, + WorkspaceStaffIdentityAnchorCutoverSourceLimits.PageSize, + cancellationToken) + .ConfigureAwait(false); + if (page?.Records is null || + page.Records.Count > + WorkspaceStaffIdentityAnchorCutoverSourceLimits.PageSize || + (page.HasMore && page.Records.Count != + WorkspaceStaffIdentityAnchorCutoverSourceLimits.PageSize)) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .SourcePageInvalid); + } + + List pending = new(page.Records.Count); + Guid? previous = afterApplicationId; + foreach (WorkspaceStaffIdentityAnchorSourceRecord record in + page.Records) + { + string normalizedSubjectId = record.SubjectId?.Trim() ?? + string.Empty; + if (record.ApplicationId == Guid.Empty || + record.StaffMemberId == Guid.Empty || + string.IsNullOrWhiteSpace(normalizedSubjectId) || + normalizedSubjectId.Any(char.IsControl) || + !string.Equals( + record.SubjectId, + normalizedSubjectId, + StringComparison.Ordinal) || + !Enum.IsDefined(record.Status) || + (previous.HasValue && + record.ApplicationId.CompareTo(previous.Value) <= 0)) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .SourcePageInvalid); + } + + previous = record.ApplicationId; + accumulator.AddWorkspaceSourceEvidence(record); + pending.Add(new PendingPlanItem( + new StaffIdentityProvisioningAnchorCandidate( + StaffIdentityProvisioningAnchorSourceKind + .WorkspaceOnboarding, + record.ApplicationId, + record.StaffMemberId, + normalizedSubjectId), + ObservedMembershipVersion: null, + ForceConflict: false)); + } + + Guid? expectedNextApplicationId = page.Records.Count == 0 + ? afterApplicationId + : page.Records[^1].ApplicationId; + if (page.NextApplicationId != expectedNextApplicationId || + (page.HasMore && + (!page.NextApplicationId.HasValue || + page.NextApplicationId == afterApplicationId))) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .SourcePageInvalid); + } + + Result inspected = await this.FlushInspectionAsync( + pending, + accumulator, + cancellationToken).ConfigureAwait(false); + if (inspected.IsFailure) + { + return inspected; + } + + if (!page.HasMore) + { + return Result.Success(); + } + + afterApplicationId = page.NextApplicationId; + } + } + + private async Task> + LoadOrganizationHeaderAsync( + Guid organizationId, + CancellationToken cancellationToken) + { + OrganizationScopeSnapshot snapshot = await organizationScopes + .GetSnapshotAsync(organizationId, cancellationToken) + .ConfigureAwait(false); + if (snapshot.Status != OrganizationScopeStatus.Open || + snapshot.Revision < 0) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .OrganizationsUnavailable); + } + + OrganizationScopeExportPage? page = await organizationScopes + .ExportAsync( + new OrganizationScopeExportRequest( + organizationId, + snapshot.Revision, + OrganizationScopeExportStore.Organization, + AfterCursor: null, + PageSize: 1), + cancellationToken).ConfigureAwait(false); + if (page is null || + page.Status == OrganizationScopeExportStatus.Stale || + page.ScopeRevision != snapshot.Revision) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .OrganizationsEvidenceChanged); + } + + OrganizationScopeOrganizationExportRecord? organization = + page.Records is { Count: 1 } + ? page.Records[0] as + OrganizationScopeOrganizationExportRecord + : null; + if (page.Status != OrganizationScopeExportStatus.Completed || + page.Store != OrganizationScopeExportStore.Organization || + page.HasMore || + organization is null || + organization.OrganizationId != organizationId || + organization.Status != OrganizationStatus.Active || + organization.ActiveOwnerCount < 0 || + organization.Version < 1) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .OrganizationsUnavailable); + } + + return Result.Success(new AuthoritativeOrganizationHeader( + snapshot.Revision, + organization.Version, + organization.ActiveOwnerCount)); + } + + private async Task StreamOrganizationMembershipsAsync( + Guid organizationId, + AuthoritativeOrganizationHeader header, + WorkspaceStaffIdentityAnchorOwnerBinding[] ownerBindings, + WorkspaceStaffIdentityAnchorPlanAccumulator accumulator, + CancellationToken cancellationToken) + { + int bindingIndex = 0; + string? afterCursor = null; + Guid? previousMembershipId = null; + List pending = new( + OrganizationScopeLifecycleLimits.MaximumPageSize); + while (true) + { + OrganizationScopeExportPage? page = await organizationScopes + .ExportAsync( + new OrganizationScopeExportRequest( + organizationId, + header.ScopeRevision, + OrganizationScopeExportStore.Memberships, + afterCursor, + OrganizationScopeLifecycleLimits.MaximumPageSize), + cancellationToken).ConfigureAwait(false); + if (page is null || + page.Status == OrganizationScopeExportStatus.Stale || + page.ScopeRevision != header.ScopeRevision) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .OrganizationsEvidenceChanged); + } + + if (page.Status != OrganizationScopeExportStatus.Completed || + page.Store != OrganizationScopeExportStore.Memberships || + page.Records is null || + page.Records.Count > + OrganizationScopeLifecycleLimits.MaximumPageSize || + (page.HasMore && page.Records.Count != + OrganizationScopeLifecycleLimits.MaximumPageSize)) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .OrganizationsUnavailable); + } + + foreach (OrganizationScopeExportRecord record in page.Records) + { + if (record is not OrganizationScopeMembershipExportRecord + membership) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .OrganizationsUnavailable); + } + + string normalizedSubjectId = membership.SubjectId?.Trim() ?? + string.Empty; + if ( + membership.OrganizationId != organizationId || + membership.MembershipId == Guid.Empty || + membership.Version < 1 || + !Enum.IsDefined(membership.Role) || + !Enum.IsDefined(membership.Status) || + string.IsNullOrWhiteSpace(normalizedSubjectId) || + normalizedSubjectId.Any(char.IsControl) || + !string.Equals( + membership.SubjectId, + normalizedSubjectId, + StringComparison.Ordinal) || + (previousMembershipId.HasValue && + membership.MembershipId.CompareTo( + previousMembershipId.Value) <= 0)) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .OrganizationsUnavailable); + } + + while (bindingIndex < ownerBindings.Length && + ownerBindings[bindingIndex].MembershipId.CompareTo( + membership.MembershipId) < 0) + { + Result flushed = await this.FlushInspectionAsync( + pending, + accumulator, + cancellationToken).ConfigureAwait(false); + if (flushed.IsFailure) + { + return flushed; + } + + accumulator.AddPlanItem(MissingBinding( + ownerBindings[bindingIndex])); + bindingIndex++; + } + + WorkspaceStaffIdentityAnchorOwnerBinding? binding = null; + if (bindingIndex < ownerBindings.Length && + ownerBindings[bindingIndex].MembershipId == + membership.MembershipId) + { + binding = ownerBindings[bindingIndex]; + bindingIndex++; + } + + bool isActiveOwner = + membership.Role == OrganizationMembershipRole.Owner && + membership.Status == + OrganizationMembershipStatus.Active; + accumulator.AddOrganizationMembershipEvidence( + membership, + normalizedSubjectId, + isActiveOwner); + previousMembershipId = membership.MembershipId; + + if (isActiveOwner || binding is not null) + { + bool forceConflict = binding is not null && + (membership.Version < + binding.ObservedMembershipVersion || + (!isActiveOwner && + binding.EvidenceKind != + WorkspaceStaffIdentityAnchorOwnerBindingEvidenceKind + .HistoricalOwnerExternalReview)); + pending.Add(new PendingPlanItem( + new StaffIdentityProvisioningAnchorCandidate( + StaffIdentityProvisioningAnchorSourceKind + .OrganizationMembership, + membership.MembershipId, + binding?.StaffMemberId, + normalizedSubjectId, + binding?.ReviewedErasedTarget ?? false), + membership.Version, + forceConflict)); + if (pending.Count == + StaffWorkspaceOnboardingAnchorCutoverLimits + .MaximumBatchSize) + { + Result flushed = await this.FlushInspectionAsync( + pending, + accumulator, + cancellationToken).ConfigureAwait(false); + if (flushed.IsFailure) + { + return flushed; + } + } + } + } + + string? expectedCursor = previousMembershipId.HasValue + ? "id:" + previousMembershipId.Value.ToString("D") + : afterCursor; + if (!string.Equals( + page.NextCursor, + expectedCursor, + StringComparison.Ordinal) || + (page.HasMore && + (string.IsNullOrWhiteSpace(page.NextCursor) || + string.Equals( + page.NextCursor, + afterCursor, + StringComparison.Ordinal)))) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .OrganizationsUnavailable); + } + + if (!page.HasMore) + { + break; + } + + afterCursor = page.NextCursor; + } + + Result finalBatch = await this.FlushInspectionAsync( + pending, + accumulator, + cancellationToken).ConfigureAwait(false); + if (finalBatch.IsFailure) + { + return finalBatch; + } + + while (bindingIndex < ownerBindings.Length) + { + accumulator.AddPlanItem(MissingBinding( + ownerBindings[bindingIndex])); + bindingIndex++; + } + + if (accumulator.AuthoritativeOwnerCount != header.ActiveOwnerCount) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .OrganizationsUnavailable); + } + + OrganizationScopeSnapshot finalSnapshot = await organizationScopes + .GetSnapshotAsync(organizationId, cancellationToken) + .ConfigureAwait(false); + return finalSnapshot.Status == OrganizationScopeStatus.Open && + finalSnapshot.Revision == header.ScopeRevision + ? Result.Success() + : Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .OrganizationsEvidenceChanged); + } + + private async Task FlushInspectionAsync( + List pending, + WorkspaceStaffIdentityAnchorPlanAccumulator accumulator, + CancellationToken cancellationToken) + { + if (pending.Count == 0) + { + return Result.Success(); + } + + StaffIdentityProvisioningAnchorCandidate[] candidates = pending + .Select(item => item.Candidate) + .ToArray(); + StaffIdentityProvisioningAnchorInspection inspection = await staff + .InspectAsync(candidates, cancellationToken) + .ConfigureAwait(false); + if (!TryValidateInspection( + candidates, + inspection, + out Dictionary<( + StaffIdentityProvisioningAnchorSourceKind, + Guid), StaffIdentityProvisioningAnchorCutoverDisposition> + bySource)) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors.StaffUnavailable); + } + + foreach (PendingPlanItem item in pending) + { + if (!bySource.TryGetValue( + (item.Candidate.SourceKind, item.Candidate.SourceId), + out StaffIdentityProvisioningAnchorCutoverDisposition + disposition)) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .StaffUnavailable); + } + + accumulator.AddPlanItem(item with + { + Disposition = item.ForceConflict + ? StaffIdentityProvisioningAnchorCutoverDisposition + .Conflict + : disposition + }); + } + + pending.Clear(); + return Result.Success(); + } + + private static PendingPlanItem MissingBinding( + WorkspaceStaffIdentityAnchorOwnerBinding binding) => new( + new StaffIdentityProvisioningAnchorCandidate( + StaffIdentityProvisioningAnchorSourceKind + .OrganizationMembership, + binding.MembershipId, + binding.StaffMemberId), + binding.ObservedMembershipVersion, + ForceConflict: true) + { + Disposition = + StaffIdentityProvisioningAnchorCutoverDisposition.Conflict + }; + + private static Result + ValidateOwnerManifest( + string tenantId, + WorkspaceStaffIdentityAnchorOwnerManifest manifest) + { + if (manifest.ContractVersion != 1 || + manifest.ReviewedAtUtc == default || + manifest.HistoricalEvidence is null || + manifest.HistoricalEvidence.Kind is not ( + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + .ReviewedHistoricalOwnerUniverse or + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + .ReviewedNoHistoricalOwnerSources) || + !IsSha256(manifest.HistoricalEvidence.EvidenceSha256) || + manifest.Bindings is null || + !Guid.TryParseExact(tenantId, "D", out Guid tenantGuid) || + !string.Equals( + tenantGuid.ToString("D"), + manifest.TenantId, + StringComparison.Ordinal)) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorOwnerBinding[]>( + WorkspaceStaffIdentityAnchorCutoverErrors + .OwnerManifestInvalid); + } + + WorkspaceStaffIdentityAnchorOwnerBinding[] bindings = manifest.Bindings + .OrderBy(binding => binding.MembershipId) + .ToArray(); + if (bindings.Select(binding => binding.MembershipId) + .Distinct().Count() != bindings.Length || + bindings.Any(binding => + binding.OrganizationId != tenantGuid || + !string.Equals( + binding.ScopeId, + binding.OrganizationId.ToString("D"), + StringComparison.Ordinal) || + binding.MembershipId == Guid.Empty || + binding.StaffMemberId == Guid.Empty || + binding.ObservedMembershipVersion < 1 || + binding.EvidenceKind is not ( + WorkspaceStaffIdentityAnchorOwnerBindingEvidenceKind + .CurrentActiveOwnerReview or + WorkspaceStaffIdentityAnchorOwnerBindingEvidenceKind + .HistoricalOwnerExternalReview)) || + (manifest.HistoricalEvidence.Kind == + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + .ReviewedNoHistoricalOwnerSources && + bindings.Any(binding => binding.EvidenceKind == + WorkspaceStaffIdentityAnchorOwnerBindingEvidenceKind + .HistoricalOwnerExternalReview))) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorOwnerBinding[]>( + WorkspaceStaffIdentityAnchorCutoverErrors + .OwnerManifestInvalid); + } + + return Result.Success(bindings); + } + + private static string ComputeOwnerManifestSha256( + WorkspaceStaffIdentityAnchorOwnerManifest manifest, + WorkspaceStaffIdentityAnchorOwnerBinding[] bindings) + { + StringBuilder canonical = new( + "bunkfy-staff-owner-identity-anchor-manifest/v1|"); + Append(canonical, manifest.ContractVersion); + Append(canonical, manifest.TenantId); + Append(canonical, manifest.ReviewedAtUtc.ToUniversalTime().ToString( + "O", + CultureInfo.InvariantCulture)); + Append(canonical, (int)manifest.HistoricalEvidence.Kind); + Append(canonical, manifest.HistoricalEvidence.EvidenceSha256); + Append(canonical, bindings.Length); + foreach (WorkspaceStaffIdentityAnchorOwnerBinding binding in bindings) + { + Append(canonical, binding.OrganizationId); + Append(canonical, binding.ScopeId); + Append(canonical, binding.MembershipId); + Append(canonical, binding.StaffMemberId); + Append(canonical, binding.ObservedMembershipVersion); + Append(canonical, (int)binding.EvidenceKind); + Append(canonical, binding.ReviewedErasedTarget ? 1 : 0); + } + + return Convert.ToHexStringLower(SHA256.HashData( + Encoding.UTF8.GetBytes(canonical.ToString()))); + } + + internal static bool TryValidateInspection( + IReadOnlyList candidates, + StaffIdentityProvisioningAnchorInspection? inspection, + out Dictionary<(StaffIdentityProvisioningAnchorSourceKind, Guid), + StaffIdentityProvisioningAnchorCutoverDisposition> bySource) + { + bySource = []; + if (inspection is null || + !inspection.IsSuccess || + !string.IsNullOrWhiteSpace(inspection.ErrorCode) || + inspection.Items is null || + inspection.Items.Count != candidates.Count) + { + return false; + } + + Dictionary<(StaffIdentityProvisioningAnchorSourceKind, Guid), + StaffIdentityProvisioningAnchorCandidate> expected = []; + foreach (StaffIdentityProvisioningAnchorCandidate candidate in + candidates) + { + if (candidate.SourceKind is not ( + StaffIdentityProvisioningAnchorSourceKind + .WorkspaceOnboarding or + StaffIdentityProvisioningAnchorSourceKind + .OrganizationMembership) || + candidate.SourceId == Guid.Empty || + !expected.TryAdd( + (candidate.SourceKind, candidate.SourceId), + candidate)) + { + return false; + } + } + + foreach (StaffIdentityProvisioningAnchorCandidateInspection item in + inspection.Items) + { + (StaffIdentityProvisioningAnchorSourceKind, Guid) key = + (item.SourceKind, item.SourceId); + if (!expected.ContainsKey(key) || + !IsAllowedDisposition(item.SourceKind, item.Disposition) || + !bySource.TryAdd(key, item.Disposition)) + { + return false; + } + } + + return bySource.Count == expected.Count; + } + + private static bool IsAllowedDisposition( + StaffIdentityProvisioningAnchorSourceKind sourceKind, + StaffIdentityProvisioningAnchorCutoverDisposition disposition) => + sourceKind switch + { + StaffIdentityProvisioningAnchorSourceKind.WorkspaceOnboarding => + disposition is + StaffIdentityProvisioningAnchorCutoverDisposition + .AlreadyAnchored or + StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromWorkspace or + StaffIdentityProvisioningAnchorCutoverDisposition + .Ambiguous or + StaffIdentityProvisioningAnchorCutoverDisposition + .Conflict, + StaffIdentityProvisioningAnchorSourceKind.OrganizationMembership => + disposition is + StaffIdentityProvisioningAnchorCutoverDisposition + .AlreadyAnchored or + StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromReviewedOwnerMap or + StaffIdentityProvisioningAnchorCutoverDisposition + .Ambiguous or + StaffIdentityProvisioningAnchorCutoverDisposition + .Conflict, + _ => false + }; + + private static void Append(StringBuilder builder, string value) => + builder.Append(value.Length.ToString(CultureInfo.InvariantCulture)) + .Append(':').Append(value); + + private static void Append(StringBuilder builder, int value) => + Append(builder, value.ToString(CultureInfo.InvariantCulture)); + + private static void Append(StringBuilder builder, long value) => + Append(builder, value.ToString(CultureInfo.InvariantCulture)); + + private static void Append(StringBuilder builder, Guid value) => + Append(builder, value.ToString("N")); + + private static bool IsSha256(string? value) => + value is { Length: 64 } && value.All(Uri.IsHexDigit); + + private sealed record WorkspaceStaffIdentityAnchorPlan( + WorkspaceStaffIdentityAnchorCutoverStatus Status, + IReadOnlyList Batch); + + private sealed record PendingPlanItem( + StaffIdentityProvisioningAnchorCandidate Candidate, + long? ObservedMembershipVersion, + bool ForceConflict) + { + public StaffIdentityProvisioningAnchorCutoverDisposition Disposition + { get; init; } + } + + private sealed record AuthoritativeOrganizationHeader( + long ScopeRevision, + long OrganizationVersion, + long ActiveOwnerCount); + + private sealed class WorkspaceStaffIdentityAnchorPlanAccumulator + : IDisposable + { + private readonly StreamingCanonicalSha256 sourceEvidence = new(); + private readonly StreamingCanonicalSha256 anchorState = new(); + private readonly int retainedBatchSize; + private readonly List + retainedBatch; + private readonly List issues = + new(WorkspaceStaffIdentityAnchorCutoverStatusLimits.MaximumIssues); + private long organizationMembershipCount; + private long planItemCount; + + public WorkspaceStaffIdentityAnchorPlanAccumulator( + string tenantId, + long organizationsScopeRevision, + long organizationVersion, + int retainedBatchSize) + { + this.OrganizationsScopeRevision = organizationsScopeRevision; + this.retainedBatchSize = retainedBatchSize; + this.retainedBatch = new(Math.Min( + retainedBatchSize, + StaffWorkspaceOnboardingAnchorCutoverLimits + .MaximumBatchSize)); + this.sourceEvidence.Append( + "bunkfy-workspaces-staff-identity-anchor-sources/v3|"); + this.sourceEvidence.Append(tenantId); + this.sourceEvidence.Append(organizationsScopeRevision); + this.sourceEvidence.Append(organizationVersion); + this.anchorState.Append( + "bunkfy-staff-identity-anchor-state/v3|"); + this.anchorState.Append(tenantId); + this.anchorState.Append(organizationsScopeRevision); + } + + public long WorkspaceSourceCount { get; private set; } + public long AlreadyAnchoredCount { get; private set; } + public long SeedableWorkspaceCount { get; private set; } + public long SeedableOwnerCount { get; private set; } + public long AmbiguousCount { get; private set; } + public long ConflictCount { get; private set; } + public long AuthoritativeOwnerCount { get; private set; } + public long TotalIssueCount { get; private set; } + + public void AddWorkspaceSourceEvidence( + WorkspaceStaffIdentityAnchorSourceRecord record) + { + this.sourceEvidence.Append("workspace"); + this.sourceEvidence.Append(record.ApplicationId); + this.sourceEvidence.Append(record.StaffMemberId); + this.sourceEvidence.Append(record.SubjectId); + this.sourceEvidence.Append((int)record.Status); + this.WorkspaceSourceCount = checked( + this.WorkspaceSourceCount + 1); + } + + public void AddOrganizationMembershipEvidence( + OrganizationScopeMembershipExportRecord membership, + string normalizedSubjectId, + bool isActiveOwner) + { + this.sourceEvidence.Append("membership"); + this.sourceEvidence.Append(membership.MembershipId); + this.sourceEvidence.Append(normalizedSubjectId); + this.sourceEvidence.Append((int)membership.Role); + this.sourceEvidence.Append((int)membership.Status); + this.sourceEvidence.Append(membership.Version); + this.organizationMembershipCount = checked( + this.organizationMembershipCount + 1); + if (isActiveOwner) + { + this.AuthoritativeOwnerCount = checked( + this.AuthoritativeOwnerCount + 1); + } + } + + public void AddPlanItem(PendingPlanItem item) + { + StaffIdentityProvisioningAnchorCutoverDisposition disposition = + item.Disposition; + this.anchorState.Append("item"); + this.anchorState.Append((int)item.Candidate.SourceKind); + this.anchorState.Append(item.Candidate.SourceId); + this.anchorState.Append(item.Candidate.StaffMemberId); + this.anchorState.AppendNullable( + item.Candidate.ExpectedAuthSubjectId); + this.anchorState.Append(item.Candidate.ReviewedErasedTarget); + this.anchorState.Append(item.ObservedMembershipVersion); + this.anchorState.Append((int)disposition); + this.planItemCount = checked(this.planItemCount + 1); + + switch (disposition) + { + case StaffIdentityProvisioningAnchorCutoverDisposition + .AlreadyAnchored: + this.AlreadyAnchoredCount = checked( + this.AlreadyAnchoredCount + 1); + break; + case StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromWorkspace: + this.SeedableWorkspaceCount = checked( + this.SeedableWorkspaceCount + 1); + this.RetainSeedable(item.Candidate); + break; + case StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromReviewedOwnerMap: + this.SeedableOwnerCount = checked( + this.SeedableOwnerCount + 1); + this.RetainSeedable(item.Candidate); + break; + case StaffIdentityProvisioningAnchorCutoverDisposition + .Ambiguous: + this.AmbiguousCount = checked(this.AmbiguousCount + 1); + this.AddIssue(item); + break; + case StaffIdentityProvisioningAnchorCutoverDisposition + .Conflict: + this.ConflictCount = checked(this.ConflictCount + 1); + this.AddIssue(item); + break; + case StaffIdentityProvisioningAnchorCutoverDisposition + .Unknown: + default: + throw new InvalidOperationException( + "The identity-anchor cutover disposition is invalid."); + } + } + + public WorkspaceStaffIdentityAnchorCutoverStatus CompleteStatus( + long ownerBindingCount, + long historicalBindingCount, + string? ownerManifestSha256, + bool ownerManifestProvided, + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + historicalEvidenceKind, + string? historicalEvidenceSha256) + { + this.sourceEvidence.Append("counts"); + this.sourceEvidence.Append(this.WorkspaceSourceCount); + this.sourceEvidence.Append(this.organizationMembershipCount); + this.sourceEvidence.Append(this.AuthoritativeOwnerCount); + this.anchorState.Append("count"); + this.anchorState.Append(this.planItemCount); + bool canReconcile = ownerManifestProvided && + this.AmbiguousCount == 0 && + this.ConflictCount == 0; + return new WorkspaceStaffIdentityAnchorCutoverStatus( + this.WorkspaceSourceCount, + ownerBindingCount, + this.AlreadyAnchoredCount, + this.SeedableWorkspaceCount, + this.SeedableOwnerCount, + this.AmbiguousCount, + this.ConflictCount, + this.sourceEvidence.Complete(), + this.anchorState.Complete(), + ownerManifestSha256, + ownerManifestProvided, + canReconcile, + canReconcile && + this.SeedableWorkspaceCount == 0 && + this.SeedableOwnerCount == 0, + this.AuthoritativeOwnerCount, + this.OrganizationsScopeRevision, + historicalBindingCount, + historicalEvidenceKind, + historicalEvidenceSha256, + this.TotalIssueCount, + this.TotalIssueCount > this.issues.Count, + this.issues.ToArray()); + } + + public long OrganizationsScopeRevision { get; private init; } + + public StaffIdentityProvisioningAnchorCandidate[] GetRetainedBatch() => + this.retainedBatch.ToArray(); + + public void Dispose() + { + this.sourceEvidence.Dispose(); + this.anchorState.Dispose(); + } + + private void RetainSeedable( + StaffIdentityProvisioningAnchorCandidate candidate) + { + if (this.retainedBatch.Count < this.retainedBatchSize) + { + this.retainedBatch.Add(candidate); + } + } + + private void AddIssue(PendingPlanItem item) + { + this.TotalIssueCount = checked(this.TotalIssueCount + 1); + if (this.issues.Count < + WorkspaceStaffIdentityAnchorCutoverStatusLimits.MaximumIssues) + { + this.issues.Add(new WorkspaceStaffIdentityAnchorCutoverIssue( + item.Candidate.SourceKind, + item.Candidate.SourceId, + item.Disposition, + item.ObservedMembershipVersion, + item.Candidate.StaffMemberId)); + } + } + + private sealed class StreamingCanonicalSha256 : IDisposable + { + private readonly IncrementalHash hash = IncrementalHash.CreateHash( + HashAlgorithmName.SHA256); + private bool completed; + + public void Append(string value) + { + ArgumentNullException.ThrowIfNull(value); + this.AppendRaw(value.Length.ToString( + CultureInfo.InvariantCulture)); + this.AppendRaw(":"); + this.AppendRaw(value); + } + + public void Append(int value) => this.Append( + value.ToString(CultureInfo.InvariantCulture)); + + public void Append(long value) => this.Append( + value.ToString(CultureInfo.InvariantCulture)); + + public void Append(Guid value) => this.Append(value.ToString("N")); + + public void Append(Guid? value) + { + this.Append(value.HasValue ? 1 : 0); + if (value.HasValue) + { + this.Append(value.Value); + } + } + + public void Append(long? value) + { + this.Append(value.HasValue ? 1 : 0); + if (value.HasValue) + { + this.Append(value.Value); + } + } + + public void AppendNullable(string? value) + { + this.Append(value is null ? 0 : 1); + if (value is not null) + { + this.Append(value); + } + } + + public void Append(bool value) => this.Append(value ? 1 : 0); + + public string Complete() + { + if (this.completed) + { + throw new InvalidOperationException( + "The canonical digest has already been completed."); + } + + this.completed = true; + return Convert.ToHexStringLower(this.hash.GetHashAndReset()); + } + + public void Dispose() => this.hash.Dispose(); + + private void AppendRaw(string value) => this.hash.AppendData( + Encoding.UTF8.GetBytes(value)); + } + } +} + +internal sealed record WorkspaceStaffIdentityAnchorPreparedReconcile( + Guid OrganizationId, + IReadOnlyList Batch, + WorkspaceStaffIdentityAnchorCutoverStatus AcceptedStatus, + WorkspaceStaffIdentityAnchorOwnerManifest OwnerManifest, + string AcceptedSourceEvidenceSha256, + string AcceptedAnchorStateSha256, + string AcceptedOwnerManifestSha256); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffIdentityAnchorCutoverHandlers.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffIdentityAnchorCutoverHandlers.cs new file mode 100644 index 00000000..8293bcb0 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffIdentityAnchorCutoverHandlers.cs @@ -0,0 +1,331 @@ +namespace BunkFy.Modules.Workspaces.Application.Handlers; + +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Application.Queries; +using BunkFy.Modules.Staff.Contracts; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Scoping; +using Microsoft.Extensions.DependencyInjection; + +internal sealed class GetWorkspaceStaffIdentityAnchorCutoverStatusQueryHandler( + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator, + IScopeContext scopeContext) + : IQueryHandler +{ + public Task> HandleAsync( + GetWorkspaceStaffIdentityAnchorCutoverStatusQuery query, + CancellationToken cancellationToken) + { + if (!WorkspaceStaffIdentityAnchorTenantScope.TryGetCanonicalTenantId( + scopeContext, + out string tenantId)) + { + return Task.FromResult(Result.Failure< + WorkspaceStaffIdentityAnchorCutoverStatus>( + WorkspaceStaffIdentityAnchorCutoverErrors.TenantRequired)); + } + + return coordinator.GetStatusAsync( + tenantId, + query.OwnerManifest, + cancellationToken); + } +} + +internal sealed class ReconcileWorkspaceStaffIdentityAnchorsCommandHandler( + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator, + IWorkspaceIdentityAnchorCutoverExecutionBoundary executionBoundary, + IWorkspaceCrossGraphMutationLock crossGraphLock, + IStaffIdentityProvisioningAnchorCutover staff, + IWorkspaceStaffIdentityAnchorFreshStatusReader freshStatusReader, + IScopeContext scopeContext) + : ICommandHandler +{ + public async Task> + HandleAsync( + ReconcileWorkspaceStaffIdentityAnchorsCommand command, + CancellationToken cancellationToken) + { + if (!WorkspaceStaffIdentityAnchorTenantScope.TryGetCanonicalTenantId( + scopeContext, + out string tenantId)) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorReconcileResult>( + WorkspaceStaffIdentityAnchorCutoverErrors.TenantRequired); + } + + Result prepared = + await executionBoundary.ExecuteAsync( + async operationCancellationToken => + { + await crossGraphLock.AcquireAsync( + operationCancellationToken) + .ConfigureAwait(false); + return await coordinator.PrepareReconcileAsync( + tenantId, + command.ExpectedSourceEvidenceSha256, + command.ExpectedAnchorStateSha256, + command.OwnerManifest, + command.ExpectedOwnerManifestSha256, + command.BatchSize, + operationCancellationToken).ConfigureAwait(false); + }, + cancellationToken).ConfigureAwait(false); + if (prepared.IsFailure) + { + return Result.Failure( + prepared.Error); + } + + return await this.ApplyAndVerifyAsync( + prepared.Value, + cancellationToken) + .ConfigureAwait(false); + } + + private async Task> + ApplyAndVerifyAsync( + WorkspaceStaffIdentityAnchorPreparedReconcile prepared, + CancellationToken cancellationToken) + { + if (prepared.Batch.Count == 0) + { + return Result.Success( + VerifiedResult(prepared, appliedCount: 0, + prepared.AcceptedStatus)); + } + + bool applyAttempted = false; + try + { + applyAttempted = true; + StaffIdentityProvisioningAnchorApplyResult applied = + await staff.ApplyAsync(prepared.Batch, cancellationToken) + .ConfigureAwait(false); + if (!IsWellFormed(applied, prepared.Batch.Count)) + { + return await this.ApplyOutcomeUnknownAsync( + prepared, + appliedCount: null) + .ConfigureAwait(false); + } + + if (!applied.IsSuccess) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorReconcileResult>( + WorkspaceStaffIdentityAnchorCutoverErrors + .StaffUnavailable); + } + + WorkspaceStaffIdentityAnchorFreshVerification? fresh = + await this.TryReadFreshVerificationAsync(prepared) + .ConfigureAwait(false); + return fresh is null || !IsExactVerifiedFreshState(prepared, fresh) + ? ApplyOutcomeUnknownResult( + prepared, + applied.AppliedCount, + fresh?.Status) + : Result.Success(VerifiedResult( + prepared, + applied.AppliedCount, + fresh.Status)); + } + catch (Exception) when (applyAttempted) + { + return await this.ApplyOutcomeUnknownAsync( + prepared, + appliedCount: null) + .ConfigureAwait(false); + } + } + + private async Task> + ApplyOutcomeUnknownAsync( + WorkspaceStaffIdentityAnchorPreparedReconcile prepared, + int? appliedCount) + { + WorkspaceStaffIdentityAnchorFreshVerification? fresh = + await this.TryReadFreshVerificationAsync(prepared) + .ConfigureAwait(false); + return ApplyOutcomeUnknownResult( + prepared, + appliedCount, + fresh?.Status); + } + + private async Task + TryReadFreshVerificationAsync( + WorkspaceStaffIdentityAnchorPreparedReconcile prepared) + { + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30)); + try + { + Result fresh = + await freshStatusReader.ReadAsync( + prepared, + timeout.Token) + .ConfigureAwait(false); + return fresh.IsSuccess ? fresh.Value : null; + } + catch (Exception) + { + return null; + } + } + + private static bool IsWellFormed( + StaffIdentityProvisioningAnchorApplyResult applied, + int attemptedCount) => + applied is not null && + applied.AppliedCount >= 0 && + applied.AlreadyAnchoredCount >= 0 && + (applied.IsSuccess + ? (long)applied.AppliedCount + applied.AlreadyAnchoredCount == + attemptedCount && + string.IsNullOrWhiteSpace(applied.ErrorCode) + : applied.AppliedCount == 0 && + applied.AlreadyAnchoredCount == 0 && + !string.IsNullOrWhiteSpace(applied.ErrorCode)); + + private static bool IsExactVerifiedFreshState( + WorkspaceStaffIdentityAnchorPreparedReconcile prepared, + WorkspaceStaffIdentityAnchorFreshVerification fresh) => + string.Equals( + prepared.AcceptedSourceEvidenceSha256, + fresh.Status.SourceEvidenceSha256, + StringComparison.OrdinalIgnoreCase) && + string.Equals( + prepared.AcceptedOwnerManifestSha256, + fresh.Status.OwnerManifestSha256, + StringComparison.OrdinalIgnoreCase) && + WorkspaceStaffIdentityAnchorCutoverCoordinator.TryValidateInspection( + prepared.Batch, + fresh.BatchInspection, + out Dictionary<(StaffIdentityProvisioningAnchorSourceKind, Guid), + StaffIdentityProvisioningAnchorCutoverDisposition> bySource) && + bySource.Values.All(disposition => disposition == + StaffIdentityProvisioningAnchorCutoverDisposition.AlreadyAnchored); + + private static WorkspaceStaffIdentityAnchorReconcileResult VerifiedResult( + WorkspaceStaffIdentityAnchorPreparedReconcile prepared, + int appliedCount, + WorkspaceStaffIdentityAnchorCutoverStatus status) => + new( + appliedCount, + status, + WorkspaceStaffIdentityAnchorReconcileOutcome.AppliedAndVerified, + MustRerunStatus: false, + prepared.AcceptedSourceEvidenceSha256, + prepared.AcceptedAnchorStateSha256, + prepared.AcceptedOwnerManifestSha256); + + private static Result + ApplyOutcomeUnknownResult( + WorkspaceStaffIdentityAnchorPreparedReconcile prepared, + int? appliedCount, + WorkspaceStaffIdentityAnchorCutoverStatus? status) => + Result.Success(new WorkspaceStaffIdentityAnchorReconcileResult( + appliedCount, + status, + WorkspaceStaffIdentityAnchorReconcileOutcome.ApplyOutcomeUnknown, + MustRerunStatus: true, + prepared.AcceptedSourceEvidenceSha256, + prepared.AcceptedAnchorStateSha256, + prepared.AcceptedOwnerManifestSha256)); +} + +internal interface IWorkspaceStaffIdentityAnchorFreshStatusReader +{ + Task> ReadAsync( + WorkspaceStaffIdentityAnchorPreparedReconcile prepared, + CancellationToken cancellationToken); +} + +internal sealed class WorkspaceStaffIdentityAnchorFreshStatusReader( + IWorkspaceAuthoritativeScope authoritativeScope) + : IWorkspaceStaffIdentityAnchorFreshStatusReader +{ + public Task> ReadAsync( + WorkspaceStaffIdentityAnchorPreparedReconcile prepared, + CancellationToken cancellationToken) => + authoritativeScope.RunAsync( + prepared.OrganizationId, + async provider => + { + IWorkspaceIdentityAnchorCutoverExecutionBoundary boundary = + provider.GetRequiredService< + IWorkspaceIdentityAnchorCutoverExecutionBoundary>(); + IWorkspaceCrossGraphMutationLock crossGraphLock = provider + .GetRequiredService(); + return await boundary.ExecuteAsync( + async operationCancellationToken => + { + await crossGraphLock.AcquireAsync( + operationCancellationToken) + .ConfigureAwait(false); + Result + status = await provider.GetRequiredService< + IRequestDispatcher>() + .QueryAsync( + new + GetWorkspaceStaffIdentityAnchorCutoverStatusQuery( + prepared.OwnerManifest), + operationCancellationToken) + .ConfigureAwait(false); + if (status.IsFailure) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorFreshVerification>( + status.Error); + } + + StaffIdentityProvisioningAnchorInspection inspection = + await provider.GetRequiredService< + IStaffIdentityProvisioningAnchorCutover>() + .InspectAsync( + prepared.Batch, + operationCancellationToken) + .ConfigureAwait(false); + return Result.Success( + new WorkspaceStaffIdentityAnchorFreshVerification( + status.Value, + inspection)); + }, + cancellationToken).ConfigureAwait(false); + }); +} + +internal sealed record WorkspaceStaffIdentityAnchorFreshVerification( + WorkspaceStaffIdentityAnchorCutoverStatus Status, + StaffIdentityProvisioningAnchorInspection BatchInspection); + +internal static class WorkspaceStaffIdentityAnchorTenantScope +{ + public static bool TryGetCanonicalTenantId( + IScopeContext scopeContext, + out string tenantId) + { + tenantId = string.Empty; + if (!scopeContext.IsEnabled || + !Guid.TryParseExact(scopeContext.ScopeId, "D", out Guid parsed)) + { + return false; + } + + string canonical = parsed.ToString("D"); + if (!string.Equals(scopeContext.ScopeId, canonical, + StringComparison.Ordinal)) + { + return false; + } + + tenantId = canonical; + return true; + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffIdentityAnchorSweepCommandHandlers.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffIdentityAnchorSweepCommandHandlers.cs new file mode 100644 index 00000000..15ba9f13 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffIdentityAnchorSweepCommandHandlers.cs @@ -0,0 +1,324 @@ +namespace BunkFy.Modules.Workspaces.Application.Handlers; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Models; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Application.Queries; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Time; +using Gma.Framework.Scoping; + +internal sealed class PrepareWorkspaceStaffIdentityAnchorSweepPageCommandHandler( + IWorkspaceStaffIdentityAnchorSweepRepository repository, + IWorkspaceCrossGraphMutationLock crossGraphLock, + IScopeContext scopeContext, + ISystemClock clock) + : ICommandHandler< + PrepareWorkspaceStaffIdentityAnchorSweepPageCommand, + WorkspaceStaffIdentityAnchorSweepPage> +{ + public async Task> HandleAsync( + PrepareWorkspaceStaffIdentityAnchorSweepPageCommand command, + CancellationToken cancellationToken) + { + if (!WorkspaceStaffIdentityAnchorTenantScope.TryGetCanonicalTenantId( + scopeContext, + out string tenantId)) + { + return Result.Failure( + WorkspaceStaffOnboardingApplicationErrors.ScopeRequired); + } + + // Identity values are allocated before transaction commit. Draining + // tenant writers before the high-water read prevents a late commit + // from appearing behind an already captured cycle upper bound. + await crossGraphLock.AcquireAsync(cancellationToken) + .ConfigureAwait(false); + return await repository.PreparePageAsync( + tenantId, + command.CheckpointId, + command.CycleId, + command.EmptyAdvanceId, + command.RunId, + command.BatchSize, + clock.UtcNow, + cancellationToken) + .ConfigureAwait(false); + } + +} + +internal sealed class AdvanceWorkspaceStaffIdentityAnchorSweepCommandHandler( + IWorkspaceStaffIdentityAnchorSweepRepository repository, + IScopeContext scopeContext, + ISystemClock clock) + : ICommandHandler +{ + public async Task> HandleAsync( + AdvanceWorkspaceStaffIdentityAnchorSweepCommand command, + CancellationToken cancellationToken) + { + if (!WorkspaceStaffIdentityAnchorTenantScope.TryGetCanonicalTenantId( + scopeContext, + out _)) + { + return Result.Failure( + WorkspaceStaffOnboardingApplicationErrors.ScopeRequired); + } + + Result advanced = await repository.AdvanceAsync( + command.Advance, + clock.UtcNow, + cancellationToken) + .ConfigureAwait(false); + return advanced.IsSuccess + ? Result.Success(Unit.Value) + : Result.Failure(advanced.Error); + } +} + +internal sealed class GetWorkspaceStaffIdentityAnchorSweepStatusQueryHandler( + IWorkspaceStaffIdentityAnchorSweepRepository repository, + IScopeContext scopeContext) + : IQueryHandler< + GetWorkspaceStaffIdentityAnchorSweepStatusQuery, + WorkspaceStaffIdentityAnchorSweepStatus> +{ + public async Task> + HandleAsync( + GetWorkspaceStaffIdentityAnchorSweepStatusQuery query, + CancellationToken cancellationToken) + { + if (!WorkspaceStaffIdentityAnchorTenantScope.TryGetCanonicalTenantId( + scopeContext, + out string tenantId)) + { + return Result.Failure( + WorkspaceStaffOnboardingApplicationErrors.ScopeRequired); + } + + WorkspaceStaffIdentityAnchorSweepStatus status = + await repository.GetStatusAsync( + tenantId, + cancellationToken) + .ConfigureAwait(false); + return Result.Success(status); + } +} + +internal sealed class + ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommandHandler( + WorkspaceStaffOnboardingMutationCoordinator mutations, + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence, + WorkspaceStaffOnboardingProcessor processor) + : ICommandHandler< + ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommand, + WorkspaceStaffIdentityAnchorSweepCandidateResult> +{ + public async Task> + HandleAsync( + ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommand command, + CancellationToken cancellationToken) + { + if (command.ApplicationId == Guid.Empty) + { + return Failure(); + } + + WorkspaceStaffOnboardingMutationLease lease = + await mutations.AcquireExistingAsync( + command.ApplicationId, + WorkspaceStaffOnboardingSourceLockMode.Write, + requireOperational: false, + cancellationToken) + .ConfigureAwait(false); + if (lease.Application is null) + { + return Result.Success( + new WorkspaceStaffIdentityAnchorSweepCandidateResult( + command.ApplicationId, + WorkspaceStaffIdentityAnchorSweepCandidateOutcome + .Removed)); + } + + WorkspaceStaffOnboarding application = lease.Application; + bool wasObserved = + application.IdentityAnchorResolutionObservedAtUtc.HasValue; + Result + converged = await convergence.ConvergeAcquiredAsync( + application, + cancellationToken) + .ConfigureAwait(false); + if (converged.IsFailure) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorSweepCandidateResult>( + converged.Error); + } + + if (converged.Value.Outcome == + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome.Active) + { + Result processed = await processor.ProcessAcquiredAsync( + application, + cancellationToken) + .ConfigureAwait(false); + if (processed.IsFailure) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorSweepCandidateResult>( + processed.Error); + } + } + + return Describe( + application, + wasObserved, + converged.Value.Outcome); + } + + private static Result + Describe( + WorkspaceStaffOnboarding application, + bool wasObserved, + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome outcome) + { + if (application.IdentityAnchorResolutionObservedAtUtc.HasValue) + { + return Success( + application.Id, + wasObserved + ? WorkspaceStaffIdentityAnchorSweepCandidateOutcome + .AlreadyObserved + : WorkspaceStaffIdentityAnchorSweepCandidateOutcome + .ObservedNow); + } + + bool hasAnyResolutionCoordinate = + application.IdentityAnchorResolutionEventId.HasValue || + application.IdentityAnchorResolutionStaffMemberId.HasValue || + application.IdentityAnchorResolutionApplicationVersion.HasValue || + application.IdentityAnchorResolutionDisposition.HasValue || + application.IdentityAnchorResolutionIntentAtUtc.HasValue; + if (hasAnyResolutionCoordinate) + { + if (!TryCreateResolutionRequest( + application, + out StaffWorkspaceOnboardingIdentityAnchorResolutionRequest? + request)) + { + return Failure(); + } + + return Result.Success( + new WorkspaceStaffIdentityAnchorSweepCandidateResult( + application.Id, + WorkspaceStaffIdentityAnchorSweepCandidateOutcome + .ResolutionReadyToRecord, + request)); + } + + return outcome switch + { + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome.Absent => + Success( + application.Id, + WorkspaceStaffIdentityAnchorSweepCandidateOutcome + .NoAnchor), + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ConvergedNow => Success( + application.Id, + WorkspaceStaffIdentityAnchorSweepCandidateOutcome + .PassOneCommitted), + _ => Failure() + }; + } + + private static bool TryCreateResolutionRequest( + WorkspaceStaffOnboarding application, + out StaffWorkspaceOnboardingIdentityAnchorResolutionRequest? request) + { + request = null; + if (!application.IdentityAnchorResolutionEventId.HasValue || + !application.IdentityAnchorResolutionStaffMemberId.HasValue || + !application.IdentityAnchorResolutionApplicationVersion.HasValue || + !application.IdentityAnchorResolutionDisposition.HasValue || + !application.IdentityAnchorResolutionIntentAtUtc.HasValue || + application.IdentityAnchorResolutionEventId.Value == Guid.Empty || + application.IdentityAnchorResolutionStaffMemberId.Value == + Guid.Empty || + application.IdentityAnchorResolutionApplicationVersion.Value <= + 0 || + application.IdentityAnchorExpectedResolutionEventId != + application.IdentityAnchorResolutionEventId || + application.StaffMemberId != + application.IdentityAnchorResolutionStaffMemberId || + !TryMapDisposition( + application.IdentityAnchorResolutionDisposition.Value, + out StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + disposition)) + { + return false; + } + + request = new( + application.IdentityAnchorResolutionEventId.Value, + application.Id, + application.IdentityAnchorResolutionStaffMemberId.Value, + application.IdentityAnchorResolutionApplicationVersion.Value, + disposition, + application.IdentityAnchorResolutionIntentAtUtc.Value); + return true; + } + + private static bool TryMapDisposition( + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition source, + out StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition target) + { + target = source switch + { + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted => + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted => + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted => + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted => + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted => + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted, + _ => StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .Unknown + }; + return target != + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .Unknown; + } + + private static Result + Success( + Guid applicationId, + WorkspaceStaffIdentityAnchorSweepCandidateOutcome outcome) => + Result.Success( + new WorkspaceStaffIdentityAnchorSweepCandidateResult( + applicationId, + outcome)); + + private static Result + Failure() => + Result.Failure( + WorkspaceStaffOnboardingApplicationErrors.IdentityAnchorConflict); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingIdentityAnchorContinuationHandler.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingIdentityAnchorContinuationHandler.cs new file mode 100644 index 00000000..769e85b3 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingIdentityAnchorContinuationHandler.cs @@ -0,0 +1,66 @@ +namespace BunkFy.Modules.Workspaces.Application.Handlers; + +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Contracts; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Messaging; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Time; + +[IntegrationEventHandler( + WorkspacesModuleMetadata.StaffOnboardingIdentityAnchorContinuationHandlerName)] +internal sealed class WorkspaceStaffOnboardingIdentityAnchorContinuationHandler( + IWorkspaceStaffOnboardingRepository applications, + IWorkspaceStaffAccessPlanRepository plans, + IWorkspaceStaffDeferredClaimWithdrawalRepository deferredWithdrawals, + WorkspaceStaffOnboardingProcessor processor, + ISystemClock clock) + : IIntegrationEventHandler< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent> +{ + public async Task HandleAsync( + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + integrationEvent, + CancellationToken cancellationToken) + { + WorkspaceStaffOnboarding? application = await applications.GetAsync( + integrationEvent.ApplicationId, + cancellationToken).ConfigureAwait(false); + if (application is null || + !string.Equals( + application.ScopeId, + integrationEvent.ScopeId, + StringComparison.Ordinal) || + application.StaffMemberId != integrationEvent.StaffMemberId || + application.IdentityAnchorContinuationEventId != + integrationEvent.EventId) + { + throw new InvalidOperationException( + "The identity-anchor continuation coordinates are unavailable."); + } + + Result processed = await processor.ProcessIdentityAnchorContinuationAsync( + application, + integrationEvent.StaffMemberId, + cancellationToken).ConfigureAwait(false); + if (processed.IsFailure) + { + throw new InvalidOperationException( + $"Identity-anchor continuation failed with '{processed.Error.Code}'."); + } + + if (application.Status == WorkspaceStaffOnboardingState.Completed && + application.SourceKind == + WorkspaceStaffOnboardingSource.EnrollmentLink) + { + await OrganizationEnrollmentClaimExpiredStaffOnboardingHandler + .ExpirePlanWhenUnusedUnderSourceLockAsync( + applications, + plans, + deferredWithdrawals, + application.SourceId, + clock.UtcNow, + cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingIdentityAnchorContinuationOutboxProjector.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingIdentityAnchorContinuationOutboxProjector.cs new file mode 100644 index 00000000..d627f4ce --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingIdentityAnchorContinuationOutboxProjector.cs @@ -0,0 +1,27 @@ +namespace BunkFy.Modules.Workspaces.Application.Handlers; + +using BunkFy.Modules.Workspaces.Contracts; +using BunkFy.Modules.Workspaces.Domain.Events; +using Gma.Framework.Application.Events; +using Gma.Framework.Messaging; + +internal sealed class + WorkspaceStaffOnboardingIdentityAnchorContinuationOutboxProjector( + IOutboxWriterRegistry outboxWriters) + : IDomainEventHandler< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent> +{ + public Task HandleAsync( + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent + domainEvent, + CancellationToken cancellationToken) => + outboxWriters.GetRequired(WorkspacesModuleMetadata.Name).EnqueueAsync( + new + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent( + domainEvent.EventId, + domainEvent.ScopeId, + domainEvent.OccurredAtUtc, + domainEvent.ApplicationId, + domainEvent.StaffMemberId), + cancellationToken); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingIdentityAnchorConvergence.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingIdentityAnchorConvergence.cs new file mode 100644 index 00000000..c3af5d1f --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingIdentityAnchorConvergence.cs @@ -0,0 +1,588 @@ +namespace BunkFy.Modules.Workspaces.Application.Handlers; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; + +internal enum WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome +{ + Absent = 1, + ConvergedNow = 2, + Active = 3, + ResolutionPending = 4, + ResolutionObserved = 5 +} + +internal readonly record struct + WorkspaceStaffOnboardingIdentityAnchorConvergenceResult( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome Outcome); + +internal sealed class WorkspaceStaffOnboardingIdentityAnchorConvergence( + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader outcomes, + WorkspaceStaffAccessMutationCoordinator accessMutations, + IWorkspaceStaffAccessProcessRepository accessProcesses, + WorkspaceAccessProvisioner access, + ISystemClock clock, + IIdGenerator ids) +{ + public async Task> + ConvergeAcquiredAsync( + WorkspaceStaffOnboarding application, + CancellationToken cancellationToken, + Guid? expectedStaffMemberId = null, + Guid? expectedResolutionEventId = null) + { + ArgumentNullException.ThrowIfNull(application); + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome = + await outcomes.ReadAsync( + new StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest( + application.Id, + application.SubjectId), + cancellationToken).ConfigureAwait(false); + + if (outcome.ApplicationId != application.Id) + { + return Conflict(); + } + + if (expectedStaffMemberId.HasValue && + (expectedStaffMemberId.Value == Guid.Empty || + outcome.Status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Absent || + outcome.StaffMemberId != expectedStaffMemberId)) + { + return Conflict(); + } + + if (expectedResolutionEventId.HasValue && + (expectedResolutionEventId.Value == Guid.Empty || + outcome.ResolutionEventId != expectedResolutionEventId)) + { + return Conflict(); + } + + return outcome.Status switch + { + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Absent => + HandleAbsent(application, outcome), + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved => + await this.HandleUnresolvedAsync( + application, + outcome, + cancellationToken).ConfigureAwait(false), + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved => + this.HandleResolved(application, outcome), + _ => Conflict() + }; + } + + private static Result + HandleAbsent( + WorkspaceStaffOnboarding application, + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome) + { + if (application.StaffMemberId.HasValue || + application.IdentityAnchorResolutionEventId.HasValue || + outcome.StaffMemberId.HasValue || + outcome.TargetLifecycle != + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Unknown || + outcome.SubjectMatch != + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Unknown || + outcome.WorkspaceApplicationVersion.HasValue || + outcome.ResolutionDisposition.HasValue || + outcome.ResolutionEventId.HasValue) + { + return Conflict(); + } + + return Success( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome.Absent); + } + + private async Task> + HandleUnresolvedAsync( + WorkspaceStaffOnboarding application, + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome, + CancellationToken cancellationToken) + { + if (!outcome.StaffMemberId.HasValue || + outcome.StaffMemberId.Value == Guid.Empty || + !outcome.ResolutionEventId.HasValue || + outcome.ResolutionEventId.Value == Guid.Empty || + outcome.ResolutionEventId.Value == application.Id || + outcome.WorkspaceApplicationVersion.HasValue || + outcome.ResolutionDisposition.HasValue || + outcome.TargetLifecycle is + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Unknown or + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Missing) + { + return Conflict(); + } + + Guid staffMemberId = outcome.StaffMemberId.Value; + if (!HasSupportedSubjectRelationship(outcome)) + { + return Conflict(); + } + + if (outcome.SubjectMatch == + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Mismatch) + { + Result + historicalFenced = + await this.ReadUnderLifecycleFenceAsync( + application, + staffMemberId, + cancellationToken).ConfigureAwait(false); + if (historicalFenced.IsFailure) + { + return Result.Failure< + WorkspaceStaffOnboardingIdentityAnchorConvergenceResult>( + historicalFenced.Error); + } + + return await this.ApplyUnresolvedUnderFenceAsync( + application, + historicalFenced.Value, + cancellationToken).ConfigureAwait(false); + } + + if (outcome.TargetLifecycle == + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active && + !IsTerminal(application.Status)) + { + return await this.ApplyUnresolvedUnderFenceAsync( + application, + outcome, + cancellationToken).ConfigureAwait(false); + } + + Result fenced = + await this.ReadUnderLifecycleFenceAsync( + application, + staffMemberId, + cancellationToken).ConfigureAwait(false); + if (fenced.IsFailure) + { + return Result.Failure< + WorkspaceStaffOnboardingIdentityAnchorConvergenceResult>( + fenced.Error); + } + + return await this.ApplyUnresolvedUnderFenceAsync( + application, + fenced.Value, + cancellationToken).ConfigureAwait(false); + } + + public async Task> + FenceActiveGrantAcquiredAsync( + WorkspaceStaffOnboarding application, + Guid expectedStaffMemberId, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(application); + if (expectedStaffMemberId == Guid.Empty || + application.StaffMemberId != expectedStaffMemberId || + application.IdentityAnchorResolutionEventId.HasValue) + { + return Conflict(); + } + + Result fenced = + await this.ReadUnderLifecycleFenceAsync( + application, + expectedStaffMemberId, + cancellationToken).ConfigureAwait(false); + if (fenced.IsFailure) + { + return Result.Failure< + WorkspaceStaffOnboardingIdentityAnchorConvergenceResult>( + fenced.Error); + } + + if (fenced.Value.Status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved) + { + return await this.ApplyUnresolvedUnderFenceAsync( + application, + fenced.Value, + cancellationToken).ConfigureAwait(false); + } + + return fenced.Value.Status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved + ? this.HandleResolved(application, fenced.Value) + : Conflict(); + } + + private async Task> + ApplyUnresolvedUnderFenceAsync( + WorkspaceStaffOnboarding application, + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome, + CancellationToken cancellationToken) + { + if (!IsValidUnresolved(outcome) || + !HasSupportedSubjectRelationship(outcome) || + outcome.ResolutionEventId == application.Id) + { + return Conflict(); + } + + Guid staffMemberId = outcome.StaffMemberId!.Value; + Guid resolutionEventId = outcome.ResolutionEventId!.Value; + if (outcome.SubjectMatch == + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Mismatch) + { + await access.DenyMemberAsync( + application.ScopeId, + application.SubjectId, + cancellationToken).ConfigureAwait(false); + Result historicalTarget = + application.ConvergeMismatchedStaffAnchor( + staffMemberId, + resolutionEventId, + clock.UtcNow); + return historicalTarget.IsSuccess + ? Success( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionPending) + : Conflict(); + } + + if (IsTerminal(application.Status)) + { + bool completedActiveTarget = + application.Status == WorkspaceStaffOnboardingState.Completed && + outcome.SubjectMatch == + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact && + outcome.TargetLifecycle == + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active; + if (!completedActiveTarget && + !application.IdentityAnchorResolutionEventId.HasValue) + { + await access.DenyMemberAsync( + application.ScopeId, + application.SubjectId, + cancellationToken).ConfigureAwait(false); + } + + Result localTerminal = application.ConvergeTerminalStaffAnchor( + staffMemberId, + resolutionEventId, + clock.UtcNow); + return localTerminal.IsSuccess + ? Success( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionPending) + : Conflict(); + } + + if (outcome.TargetLifecycle == + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active) + { + if (outcome.SubjectMatch != + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact || + application.IdentityAnchorResolutionEventId.HasValue) + { + return Conflict(); + } + + long version = application.Version; + Guid continuationEventId = + application.IdentityAnchorContinuationEventId ?? + this.CreateContinuationEventId( + application.Id, + resolutionEventId); + if (continuationEventId == Guid.Empty) + { + return Conflict(); + } + + Result converged = application.ConvergeCommittedStaffAnchor( + staffMemberId, + resolutionEventId, + continuationEventId, + clock.UtcNow); + if (converged.IsFailure) + { + return Conflict(); + } + + if (application.Version != version) + { + return Success( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ConvergedNow); + } + + return Success( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome.Active); + } + + if (outcome.TargetLifecycle == + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Suspended) + { + if (outcome.SubjectMatch != + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact || + application.IdentityAnchorResolutionEventId.HasValue) + { + return Conflict(); + } + + await access.DenyMemberAsync( + application.ScopeId, + application.SubjectId, + cancellationToken).ConfigureAwait(false); + Result suspendedTerminal = application.ConvergeTerminalStaffAnchor( + staffMemberId, + resolutionEventId, + clock.UtcNow); + return suspendedTerminal.IsSuccess + ? Success( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionPending) + : Conflict(); + } + + if (outcome.TargetLifecycle is not ( + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Departed or + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Anonymised)) + { + return Conflict(); + } + + await access.DenyMemberAsync( + application.ScopeId, + application.SubjectId, + cancellationToken).ConfigureAwait(false); + Result targetTerminal = application.ConvergeTerminalStaffAnchor( + staffMemberId, + resolutionEventId, + clock.UtcNow); + return targetTerminal.IsSuccess + ? Success( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionPending) + : Conflict(); + } + + private Result + HandleResolved( + WorkspaceStaffOnboarding application, + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome) + { + if (!outcome.StaffMemberId.HasValue || + !outcome.ResolutionEventId.HasValue || + !outcome.WorkspaceApplicationVersion.HasValue || + !outcome.ResolutionDisposition.HasValue || + outcome.StaffMemberId.Value == Guid.Empty || + outcome.ResolutionEventId.Value == Guid.Empty || + application.IdentityAnchorExpectedResolutionEventId != + outcome.ResolutionEventId || + outcome.WorkspaceApplicationVersion.Value <= 0 || + !TryMapDisposition( + outcome.ResolutionDisposition.Value, + out WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + disposition)) + { + return Conflict(); + } + + bool wasObserved = + application.IdentityAnchorResolutionObservedAtUtc.HasValue; + Result observed = application.ObserveResolution( + application.IdentityAnchorResolutionEventId ?? Guid.Empty, + outcome.StaffMemberId.Value, + outcome.WorkspaceApplicationVersion.Value, + disposition, + clock.UtcNow); + if (observed.IsFailure) + { + return Conflict(); + } + + return Success(wasObserved + ? WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionPending + : WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionObserved); + } + + private async Task> + ReadUnderLifecycleFenceAsync( + WorkspaceStaffOnboarding application, + Guid staffMemberId, + CancellationToken cancellationToken) + { + await accessMutations.AcquireStaffAsync( + staffMemberId, + cancellationToken).ConfigureAwait(false); + WorkspaceStaffAccessProcess? open = + await accessProcesses.GetOpenByStaffAsync( + staffMemberId, + cancellationToken).ConfigureAwait(false); + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome = + await outcomes.ReadAsync( + new StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest( + application.Id, + application.SubjectId), + cancellationToken).ConfigureAwait(false); + if (outcome.ApplicationId != application.Id || + outcome.Status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Absent || + outcome.StaffMemberId != staffMemberId) + { + return Result.Failure< + StaffWorkspaceOnboardingIdentityAnchorOutcome>( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorConflict); + } + + if (open is null) + { + return Result.Success(outcome); + } + + bool exactSubject = outcome.SubjectMatch == + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact; + if (open.StaffMemberId != staffMemberId || + !string.Equals( + open.ScopeId, + application.ScopeId, + StringComparison.Ordinal) || + (exactSubject && !string.Equals( + open.SubjectId, + application.SubjectId, + StringComparison.Ordinal))) + { + return Result.Failure< + StaffWorkspaceOnboardingIdentityAnchorOutcome>( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorConflict); + } + + if (open.TargetState is + WorkspaceStaffAccessTargetState.Active or + WorkspaceStaffAccessTargetState.Suspended or + WorkspaceStaffAccessTargetState.Departed) + { + return Result.Failure< + StaffWorkspaceOnboardingIdentityAnchorOutcome>( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorLifecycleTransitionPending); + } + + return Result.Failure( + WorkspaceStaffOnboardingApplicationErrors.IdentityAnchorConflict); + } + + private static bool IsValidUnresolved( + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome) => + outcome.Status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved && + outcome.StaffMemberId.HasValue && + outcome.StaffMemberId.Value != Guid.Empty && + outcome.ResolutionEventId.HasValue && + outcome.ResolutionEventId.Value != Guid.Empty && + !outcome.WorkspaceApplicationVersion.HasValue && + !outcome.ResolutionDisposition.HasValue && + outcome.TargetLifecycle is not ( + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Unknown or + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Missing); + + private static bool TryMapDisposition( + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition source, + out WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition target) + { + target = source switch + { + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted, + _ => WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .Unknown + }; + return target != + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition.Unknown; + } + + private static bool IsTerminal(WorkspaceStaffOnboardingState status) => + status is WorkspaceStaffOnboardingState.Completed or + WorkspaceStaffOnboardingState.Rejected or + WorkspaceStaffOnboardingState.Superseded or + WorkspaceStaffOnboardingState.Expired or + WorkspaceStaffOnboardingState.Withdrawn; + + private static bool HasSupportedSubjectRelationship( + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome) => + outcome.TargetLifecycle switch + { + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active or + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Suspended or + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Departed => + outcome.SubjectMatch is + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact or + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Mismatch, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Anonymised => + outcome.SubjectMatch == + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Missing, + _ => false + }; + + private Guid CreateContinuationEventId( + Guid applicationId, + Guid resolutionEventId) + { + for (int attempt = 0; attempt < 8; attempt++) + { + Guid candidate = ids.NewId(); + if (candidate != Guid.Empty && + candidate != applicationId && + candidate != resolutionEventId) + { + return candidate; + } + } + + return Guid.Empty; + } + + private static Result< + WorkspaceStaffOnboardingIdentityAnchorConvergenceResult> Success( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome outcome) => + Result.Success( + new WorkspaceStaffOnboardingIdentityAnchorConvergenceResult(outcome)); + + private static Result< + WorkspaceStaffOnboardingIdentityAnchorConvergenceResult> Conflict() => + Result.Failure< + WorkspaceStaffOnboardingIdentityAnchorConvergenceResult>( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorConflict); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingIdentityAnchorResolutionOutboxProjector.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingIdentityAnchorResolutionOutboxProjector.cs new file mode 100644 index 00000000..62f57d42 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingIdentityAnchorResolutionOutboxProjector.cs @@ -0,0 +1,59 @@ +namespace BunkFy.Modules.Workspaces.Application.Handlers; + +using BunkFy.Modules.Workspaces.Contracts; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Domain.Events; +using Gma.Framework.Application.Events; +using Gma.Framework.Messaging; +using Contracts = BunkFy.Modules.Workspaces.Contracts; +using Domain = BunkFy.Modules.Workspaces.Domain; + +internal sealed class + WorkspaceStaffOnboardingIdentityAnchorResolutionOutboxProjector( + IOutboxWriterRegistry outboxWriters) + : IDomainEventHandler< + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent> +{ + public Task HandleAsync( + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent domainEvent, + CancellationToken cancellationToken) => + outboxWriters.GetRequired(WorkspacesModuleMetadata.Name).EnqueueAsync( + new WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent( + domainEvent.EventId, + domainEvent.ScopeId, + domainEvent.OccurredAtUtc, + domainEvent.ApplicationId, + domainEvent.StaffMemberId, + domainEvent.WorkspaceApplicationVersion, + ToContractDisposition(domainEvent.Disposition)), + cancellationToken); + + private static Contracts.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + ToContractDisposition( + Domain.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + disposition) => disposition switch + { + Domain.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted => + Contracts.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + Domain.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted => + Contracts.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted, + Domain.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted => + Contracts.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted, + Domain.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted => + Contracts.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted, + Domain.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted => + Contracts.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted, + _ => throw new InvalidOperationException( + "The Workspaces identity-anchor resolution disposition is invalid.") + }; +} 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..75f4fd80 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) @@ -35,18 +39,41 @@ public async Task HandleAsync( WorkspaceStaffOnboarding? application = await applications.GetAsync( integrationEvent.ApplicationId, cancellationToken).ConfigureAwait(false); - if (application is null || - application.Status != WorkspaceStaffOnboardingState.Provisioning || - application.StaffMemberId.HasValue) + if (application is null) { return; } - Result recovered = await processor.ProcessAsync( + bool provisioningWithoutAnchor = + application.Status == WorkspaceStaffOnboardingState.Provisioning && + !application.StaffMemberId.HasValue; + bool anchoredContinuation = + application.StaffMemberId.HasValue && + !application.IdentityAnchorResolutionEventId.HasValue; + if (!provisioningWithoutAnchor && !anchoredContinuation) + { + return; + } + + Result recovered = await processor.ProcessForSourceFinalizationAsync( application, cancellationToken).ConfigureAwait(false); if (recovered.IsSuccess) { + if (application.Status == WorkspaceStaffOnboardingState.Completed && + 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/WorkspaceStaffOnboardingProcessor.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingProcessor.cs index 3b369b42..bc05d012 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingProcessor.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingProcessor.cs @@ -6,6 +6,7 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; using BunkFy.Modules.Workspaces.Domain; using BunkFy.Modules.Workspaces.Domain.DataRights; using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; using Gma.Framework.Runtime.Time; using Microsoft.Extensions.Logging; @@ -15,11 +16,13 @@ internal sealed class WorkspaceStaffOnboardingProcessor( IWorkspaceStaffOnboardingProcessingRestrictionProjectionRepository restrictionProjections, WorkspaceStaffOnboardingMutationCoordinator mutations, + WorkspaceStaffOnboardingIdentityAnchorConvergence anchorConvergence, IWorkspaceStaffAccessPlanRepository plans, WorkspaceStaffAccessPlanPolicy planPolicy, WorkspaceAccessProvisioner access, WorkspaceOperationalAdmissionEvaluator operationalAdmission, ISystemClock clock, + IIdGenerator ids, ILogger logger) { public Task ProcessAsync( @@ -40,6 +43,30 @@ public Task ProcessForSourceFinalizationAsync( WorkspaceStaffOnboardingSourceLockMode.Write, cancellationToken); + public Task ProcessIdentityAnchorContinuationAsync( + WorkspaceStaffOnboarding application, + Guid expectedStaffMemberId, + CancellationToken cancellationToken) => + this.ProcessAsync( + application, + prepare: null, + WorkspaceStaffOnboardingSourceLockMode.Write, + cancellationToken, + expectedStaffMemberId); + + public Task ProcessAnchorCreatedAsync( + WorkspaceStaffOnboarding application, + Guid expectedStaffMemberId, + Guid expectedResolutionEventId, + CancellationToken cancellationToken) => + this.ProcessAsync( + application, + prepare: null, + WorkspaceStaffOnboardingSourceLockMode.Read, + cancellationToken, + expectedStaffMemberId, + expectedResolutionEventId); + public Task ProcessInvitationAcceptanceAsync( WorkspaceStaffOnboarding application, CancellationToken cancellationToken) => @@ -93,7 +120,9 @@ private async Task ProcessAsync( WorkspaceStaffOnboarding application, Func? prepare, WorkspaceStaffOnboardingSourceLockMode sourceLockMode, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Guid? expectedStaffMemberId = null, + Guid? expectedResolutionEventId = null) { ArgumentNullException.ThrowIfNull(application); if (!await mutations.AcquireTrackedAsync( @@ -109,13 +138,17 @@ private async Task ProcessAsync( return await this.ProcessAcquiredAsync( application, prepare, - cancellationToken).ConfigureAwait(false); + cancellationToken, + expectedStaffMemberId, + expectedResolutionEventId).ConfigureAwait(false); } private async Task ProcessAcquiredAsync( WorkspaceStaffOnboarding application, Func? prepare, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Guid? expectedStaffMemberId = null, + Guid? expectedResolutionEventId = null) { ArgumentNullException.ThrowIfNull(application); if (prepare is not null) @@ -127,6 +160,28 @@ private async Task ProcessAcquiredAsync( } } + Result + anchor = await anchorConvergence.ConvergeAcquiredAsync( + application, + cancellationToken, + expectedStaffMemberId, + expectedResolutionEventId).ConfigureAwait(false); + if (anchor.IsFailure) + { + return Result.Failure(anchor.Error); + } + + if (anchor.Value.Outcome is + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ConvergedNow or + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionPending or + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionObserved) + { + return Result.Success(); + } + if (application.Status == WorkspaceStaffOnboardingState.Completed) { return Result.Success(); @@ -241,15 +296,52 @@ await planPolicy.ValidateAsync( return Result.Failure(WorkspaceStaffOnboardingApplicationErrors.ProvisioningFailed); } - Result ready = application.MarkStaffReady(provisioned.StaffMemberId.Value, clock.UtcNow); + if (!provisioned.ResolutionEventId.HasValue || + provisioned.ResolutionEventId.Value == Guid.Empty || + provisioned.ResolutionEventId.Value == application.Id) + { + return Result.Failure( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorConflict); + } + + Guid continuationEventId = this.CreateContinuationEventId( + application.Id, + provisioned.ResolutionEventId.Value); + if (continuationEventId == Guid.Empty) + { + return Result.Failure( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorConflict); + } + + Result ready = application.MarkStaffReady( + provisioned.StaffMemberId.Value, + provisioned.ResolutionEventId.Value, + continuationEventId, + clock.UtcNow); if (ready.IsFailure) { return ready; } + + return Result.Success(); } else if (application.Status == WorkspaceStaffOnboardingState.Provisioning) { - Result ready = application.MarkStaffReady(application.StaffMemberId.Value, clock.UtcNow); + if (!application.IdentityAnchorExpectedResolutionEventId.HasValue || + !application.IdentityAnchorContinuationEventId.HasValue) + { + return Result.Failure( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorConflict); + } + + Result ready = application.MarkStaffReady( + application.StaffMemberId.Value, + application.IdentityAnchorExpectedResolutionEventId.Value, + application.IdentityAnchorContinuationEventId.Value, + clock.UtcNow); if (ready.IsFailure) { return ready; @@ -267,13 +359,30 @@ await planPolicy.ValidateAsync( StaffPropertyAssignmentProvisioningResult assignments = await staffProperties.ReconcileAsync( new StaffPropertyAssignmentProvisioningRequest( - application.StaffMemberId!.Value, + application.StaffMemberId.Value, propertyIds, "integration:workspaces", "Workspace Staff access plan applied."), cancellationToken).ConfigureAwait(false); if (!assignments.IsSuccess) { + Result + assignmentFailureFence = + await anchorConvergence.FenceActiveGrantAcquiredAsync( + application, + application.StaffMemberId.Value, + cancellationToken).ConfigureAwait(false); + if (assignmentFailureFence.IsFailure) + { + return Result.Failure(assignmentFailureFence.Error); + } + + if (assignmentFailureFence.Value.Outcome != + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome.Active) + { + return Result.Success(); + } + string failureCode = assignments.ErrorCode ?? WorkspaceStaffOnboardingApplicationErrors.ProvisioningFailed.Code; application.Fail(failureCode, clock.UtcNow); @@ -283,6 +392,22 @@ await staffProperties.ReconcileAsync( return Result.Failure(WorkspaceStaffOnboardingApplicationErrors.ProvisioningFailed); } + Result + grantFence = await anchorConvergence.FenceActiveGrantAcquiredAsync( + application, + application.StaffMemberId.Value, + cancellationToken).ConfigureAwait(false); + if (grantFence.IsFailure) + { + return Result.Failure(grantFence.Error); + } + + if (grantFence.Value.Outcome != + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome.Active) + { + return Result.Success(); + } + try { admitted = await this.RequireOperationalAsync( @@ -320,4 +445,22 @@ private async ValueTask RequireOperationalAsync( await operationalAdmission.EvaluateAsync( tenantId, cancellationToken).ConfigureAwait(false)); + + private Guid CreateContinuationEventId( + Guid applicationId, + Guid resolutionEventId) + { + for (int attempt = 0; attempt < 8; attempt++) + { + Guid candidate = ids.NewId(); + if (candidate != Guid.Empty && + candidate != applicationId && + candidate != resolutionEventId) + { + return candidate; + } + } + + return Guid.Empty; + } } 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..a8c9d802 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingProfileMutationAuthority.cs @@ -0,0 +1,105 @@ +namespace BunkFy.Modules.Workspaces.Application.Handlers; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Modules.Organizations.Contracts; + +internal static class WorkspaceStaffOnboardingProfileMutationAuthority +{ + public static bool HasLocalIdentityAnchorCoordinates( + WorkspaceStaffOnboarding application) + { + ArgumentNullException.ThrowIfNull(application); + return application.StaffMemberId.HasValue || + application.IdentityAnchorExpectedResolutionEventId.HasValue || + application.IdentityAnchorContinuationEventId.HasValue || + application.IdentityAnchorResolutionEventId.HasValue || + application.IdentityAnchorResolutionStaffMemberId.HasValue || + application.IdentityAnchorResolutionApplicationVersion.HasValue || + application.IdentityAnchorResolutionDisposition.HasValue || + application.IdentityAnchorResolutionIntentAtUtc.HasValue || + application.IdentityAnchorResolutionObservedAtUtc.HasValue; + } + + public static bool IsExactAbsent( + WorkspaceStaffOnboarding application, + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome) + { + ArgumentNullException.ThrowIfNull(application); + ArgumentNullException.ThrowIfNull(outcome); + return outcome.ApplicationId == application.Id && + outcome.Status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Absent && + !outcome.StaffMemberId.HasValue && + outcome.TargetLifecycle == + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Unknown && + outcome.SubjectMatch == + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Unknown && + !outcome.WorkspaceApplicationVersion.HasValue && + !outcome.ResolutionDisposition.HasValue && + !outcome.ResolutionEventId.HasValue && + !HasLocalIdentityAnchorCoordinates(application); + } + + 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/Handlers/WorkspaceStaffOnboardingQueryHandlers.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingQueryHandlers.cs index 422545f3..3721f3a0 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingQueryHandlers.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Handlers/WorkspaceStaffOnboardingQueryHandlers.cs @@ -1,5 +1,6 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; +using BunkFy.Modules.Staff.Contracts; using BunkFy.Modules.Workspaces.Application.Mapping; using BunkFy.Modules.Workspaces.Application.Ports; using BunkFy.Modules.Workspaces.Application.Queries; @@ -11,7 +12,9 @@ namespace BunkFy.Modules.Workspaces.Application.Handlers; using Gma.Framework.Scoping; internal sealed class GetOwnWorkspaceStaffOnboardingQueryHandler( - IWorkspaceStaffOnboardingRepository applications, + WorkspaceStaffOnboardingMutationCoordinator mutations, + IWorkspaceStaffOnboardingSerializedReadBoundary readBoundary, + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader anchorOutcomes, WorkspaceOperationalAdmissionEvaluator operationalAdmission, IScopeContext scopeContext) : IQueryHandler @@ -19,27 +22,55 @@ internal sealed class GetOwnWorkspaceStaffOnboardingQueryHandler( public async Task> HandleAsync( GetOwnWorkspaceStaffOnboardingQuery query, CancellationToken cancellationToken) - { - Result admitted = WorkspaceOperationalAdmissionGuard.RequireAllowed( - await operationalAdmission.EvaluateAsync( - scopeContext.ScopeId ?? string.Empty, - cancellationToken).ConfigureAwait(false)); - if (admitted.IsFailure) + => await readBoundary.RunAsync( + async readToken => { - return Result.Failure(admitted.Error); - } + Result admitted = WorkspaceOperationalAdmissionGuard.RequireAllowed( + await operationalAdmission.EvaluateAsync( + scopeContext.ScopeId ?? string.Empty, + readToken).ConfigureAwait(false)); + if (admitted.IsFailure) + { + return Result.Failure( + admitted.Error); + } - WorkspaceStaffOnboarding? application = await applications - .GetOperationalBySourceAndSubjectAsync( - query.SourceKind.ToDomain(), - query.SourceId, - query.SubjectId, - cancellationToken).ConfigureAwait(false); - return application is null - ? Result.Failure( - WorkspaceStaffOnboardingApplicationErrors.ApplicationNotFound) - : Result.Success(application.ToDto()); - } + WorkspaceStaffOnboardingMutationLease lease = + await mutations.AcquireApplicantAsync( + query.SourceKind.ToDomain(), + query.SourceId, + query.SubjectId, + WorkspaceStaffOnboardingSourceLockMode.Read, + requireOperational: true, + readToken).ConfigureAwait(false); + if (lease.CoordinateExists && lease.Application is null) + { + return Result.Failure( + WorkspaceStaffOnboardingApplicationErrors + .ProcessingRestricted); + } + + WorkspaceStaffOnboarding? application = lease.Application; + if (application is null) + { + return Result.Failure( + WorkspaceStaffOnboardingApplicationErrors + .ApplicationNotFound); + } + + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome = + await anchorOutcomes.ReadAsync( + new StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest( + application.Id, + application.SubjectId), + readToken).ConfigureAwait(false); + return WorkspaceStaffOnboardingProfileMutationAuthority + .IsExactAbsent(application, outcome) + ? Result.Success(application.ToDto()) + : Result.Failure( + WorkspaceStaffOnboardingApplicationErrors + .ProfileMutationAuthorityUnavailable); + }, cancellationToken).ConfigureAwait(false); } internal sealed class ListActionableWorkspaceStaffOnboardingQueryHandler( diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Mapping/WorkspaceStaffAccessMappings.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Mapping/WorkspaceStaffAccessMappings.cs index 987437e4..89b4a0db 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Mapping/WorkspaceStaffAccessMappings.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Mapping/WorkspaceStaffAccessMappings.cs @@ -2,6 +2,10 @@ namespace BunkFy.Modules.Workspaces.Application.Mapping; using BunkFy.Modules.Workspaces.Contracts; using BunkFy.Modules.Workspaces.Domain; +using ContractRestorationDisposition = + BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffAccessRestorationDisposition; +using DomainRestorationDisposition = + BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessRestorationDisposition; public static class WorkspaceStaffAccessMappings { @@ -10,6 +14,7 @@ public static class WorkspaceStaffAccessMappings Guid.Parse(process.ScopeId), process.StaffMemberId, MapTargetStatus(process.TargetState), + MapRestorationDisposition(process.RestorationDisposition), process.TargetStaffVersion, process.EffectiveOn, MapStatus(process.State), @@ -29,6 +34,20 @@ public static WorkspaceStaffAccessTargetStatus MapTargetStatus( _ => WorkspaceStaffAccessTargetStatus.Unknown }; + public static ContractRestorationDisposition + MapRestorationDisposition( + DomainRestorationDisposition disposition) => + disposition switch + { + DomainRestorationDisposition.NotApplicable => + ContractRestorationDisposition.NotApplicable, + DomainRestorationDisposition.RestoreSnapshot => + ContractRestorationDisposition.RestoreSnapshot, + DomainRestorationDisposition.Suppressed => + ContractRestorationDisposition.Suppressed, + _ => ContractRestorationDisposition.Unknown + }; + public static WorkspaceStaffAccessProcessStatus MapStatus( WorkspaceStaffAccessProcessState status) => status switch { diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Mapping/WorkspaceStaffOnboardingMappings.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Mapping/WorkspaceStaffOnboardingMappings.cs index 91394925..cd6f8832 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Mapping/WorkspaceStaffOnboardingMappings.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Mapping/WorkspaceStaffOnboardingMappings.cs @@ -56,4 +56,30 @@ public static WorkspaceStaffOnboardingSourceKind ToContract( application.FailureCode, application.CreatedAtUtc, application.LastChangedAtUtc); + + public static WorkspaceStaffOnboardingStatus ToContract( + this WorkspaceStaffOnboardingState status) => status switch + { + WorkspaceStaffOnboardingState.Submitted => + WorkspaceStaffOnboardingStatus.Submitted, + WorkspaceStaffOnboardingState.PendingApproval => + WorkspaceStaffOnboardingStatus.PendingApproval, + WorkspaceStaffOnboardingState.Provisioning => + WorkspaceStaffOnboardingStatus.Provisioning, + WorkspaceStaffOnboardingState.StaffReady => + WorkspaceStaffOnboardingStatus.StaffReady, + WorkspaceStaffOnboardingState.Completed => + WorkspaceStaffOnboardingStatus.Completed, + WorkspaceStaffOnboardingState.Failed => + WorkspaceStaffOnboardingStatus.Failed, + WorkspaceStaffOnboardingState.Rejected => + WorkspaceStaffOnboardingStatus.Rejected, + WorkspaceStaffOnboardingState.Superseded => + WorkspaceStaffOnboardingStatus.Superseded, + WorkspaceStaffOnboardingState.Expired => + WorkspaceStaffOnboardingStatus.Expired, + WorkspaceStaffOnboardingState.Withdrawn => + WorkspaceStaffOnboardingStatus.Withdrawn, + _ => WorkspaceStaffOnboardingStatus.Unknown + }; } diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Models/WorkspaceStaffIdentityAnchorSweepModels.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Models/WorkspaceStaffIdentityAnchorSweepModels.cs new file mode 100644 index 00000000..7d4f56d1 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Models/WorkspaceStaffIdentityAnchorSweepModels.cs @@ -0,0 +1,82 @@ +namespace BunkFy.Modules.Workspaces.Application.Models; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Domain; + +public sealed record WorkspaceStaffIdentityAnchorSweepCandidate( + Guid ApplicationId, + long IdentityAnchorSweepOrdinal, + string SubjectId, + bool HasLocalAnchorState); + +public sealed record WorkspaceStaffIdentityAnchorSweepPage( + Guid CheckpointId, + long CheckpointVersion, + Guid CycleId, + long? UpperOrdinal, + long? ExpectedAfterOrdinal, + long? NextAfterOrdinal, + bool ReachedEnd, + bool AdvanceRequired, + IReadOnlyList Candidates); + +public sealed record WorkspaceStaffIdentityAnchorSweepAdvance( + Guid CheckpointId, + long ExpectedCheckpointVersion, + Guid ExpectedCycleId, + long? ExpectedAfterOrdinal, + long NextAfterOrdinal, + bool ReachedEnd, + Guid AdvanceId, + Guid RunId, + WorkspaceStaffIdentityAnchorSweepPageCounts Counts); + +public enum WorkspaceStaffIdentityAnchorSweepCandidateOutcome +{ + Unknown = 0, + NoAnchor = 1, + Removed = 2, + PassOneCommitted = 3, + ResolutionReadyToRecord = 4, + ObservedNow = 5, + AlreadyObserved = 6 +} + +public sealed record WorkspaceStaffIdentityAnchorSweepCandidateResult( + Guid ApplicationId, + WorkspaceStaffIdentityAnchorSweepCandidateOutcome Outcome, + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest? + ResolutionRequest = null); + +/// +/// A completed bounded cycle is not a point-in-time database snapshot. +/// Store-generated ordinals are ordered when allocated, so release assurance +/// still requires a stable-universe barrier that excludes late commits. +/// +public sealed record WorkspaceStaffIdentityAnchorSweepStatus( + string ScopeId, + bool HasCheckpoint, + int ProtocolVersion, + long CheckpointVersion, + bool HasActiveCycle, + Guid? CycleId, + long? CycleUpperOrdinal, + long? AfterOrdinal, + DateTimeOffset? CycleStartedAtUtc, + WorkspaceStaffIdentityAnchorSweepPageCounts CurrentCycle, + Guid? LastCompletedCycleId, + long? LastCompletedUpperOrdinal, + DateTimeOffset? LastCompletedAtUtc, + WorkspaceStaffIdentityAnchorSweepPageCounts LastCompletedCycle, + Guid? LastRunId, + DateTimeOffset? UpdatedAtUtc, + bool HasCompletedBoundedCycle) +{ + public bool HasCompletedCycleObservation => + this.LastCompletedAtUtc.HasValue; + + public long? LastCompletedObservedBacklogCount => + this.HasCompletedCycleObservation + ? this.LastCompletedCycle.BacklogCount + : null; +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceIdentityAnchorCutoverExecutionBoundary.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceIdentityAnchorCutoverExecutionBoundary.cs new file mode 100644 index 00000000..9765954b --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceIdentityAnchorCutoverExecutionBoundary.cs @@ -0,0 +1,10 @@ +namespace BunkFy.Modules.Workspaces.Application.Ports; + +using Gma.Framework.Results; + +public interface IWorkspaceIdentityAnchorCutoverExecutionBoundary +{ + Task> ExecuteAsync( + Func>> operation, + CancellationToken cancellationToken); +} 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/Ports/IWorkspaceStaffHistoricalNoProvisionReceiptRepository.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffHistoricalNoProvisionReceiptRepository.cs new file mode 100644 index 00000000..9f4830fd --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffHistoricalNoProvisionReceiptRepository.cs @@ -0,0 +1,18 @@ +namespace BunkFy.Modules.Workspaces.Application.Ports; + +using BunkFy.Modules.Workspaces.Domain; + +public interface IWorkspaceStaffHistoricalNoProvisionReceiptRepository +{ + Task FindByOperationIdAsync( + Guid operationId, + CancellationToken cancellationToken); + + Task FindByApplicationIdAsync( + Guid applicationId, + CancellationToken cancellationToken); + + Task AddAsync( + WorkspaceStaffHistoricalNoProvisionReceipt receipt, + CancellationToken cancellationToken); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffIdentityAnchorCutoverSourceReader.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffIdentityAnchorCutoverSourceReader.cs new file mode 100644 index 00000000..9ebb28c7 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffIdentityAnchorCutoverSourceReader.cs @@ -0,0 +1,27 @@ +namespace BunkFy.Modules.Workspaces.Application.Ports; + +using BunkFy.Modules.Workspaces.Domain; + +public interface IWorkspaceStaffIdentityAnchorCutoverSourceReader +{ + Task ListRelevantPageAsync( + Guid? afterApplicationId, + int pageSize, + CancellationToken cancellationToken); +} + +public static class WorkspaceStaffIdentityAnchorCutoverSourceLimits +{ + public const int PageSize = 200; +} + +public sealed record WorkspaceStaffIdentityAnchorSourcePage( + IReadOnlyList Records, + Guid? NextApplicationId, + bool HasMore); + +public sealed record WorkspaceStaffIdentityAnchorSourceRecord( + Guid ApplicationId, + Guid? StaffMemberId, + string SubjectId, + WorkspaceStaffOnboardingState Status); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffIdentityAnchorSweepRepository.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffIdentityAnchorSweepRepository.cs new file mode 100644 index 00000000..4460502b --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffIdentityAnchorSweepRepository.cs @@ -0,0 +1,29 @@ +namespace BunkFy.Modules.Workspaces.Application.Ports; + +using BunkFy.Modules.Workspaces.Application.Models; +using Gma.Framework.Results; + +public interface IWorkspaceStaffIdentityAnchorSweepRepository +{ + Task> PreparePageAsync( + string scopeId, + Guid checkpointId, + Guid cycleId, + Guid emptyAdvanceId, + Guid runId, + int batchSize, + DateTimeOffset nowUtc, + CancellationToken cancellationToken); + + Task AdvanceAsync( + WorkspaceStaffIdentityAnchorSweepAdvance advance, + DateTimeOffset nowUtc, + CancellationToken cancellationToken); + + Task GetStatusAsync( + string scopeId, + CancellationToken cancellationToken); + + IAsyncEnumerable StreamScheduleScopeIdsAsync( + CancellationToken cancellationToken); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffIdentityAnchorSweepTransactionDispatcher.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffIdentityAnchorSweepTransactionDispatcher.cs new file mode 100644 index 00000000..7de01033 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffIdentityAnchorSweepTransactionDispatcher.cs @@ -0,0 +1,26 @@ +namespace BunkFy.Modules.Workspaces.Application.Ports; + +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Models; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Tasks; + +public interface IWorkspaceStaffIdentityAnchorSweepTransactionDispatcher +{ + Task> PreparePageAsync( + TaskExecutionContext context, + PrepareWorkspaceStaffIdentityAnchorSweepPageCommand command, + CancellationToken cancellationToken); + + Task> + ReconcileCandidateAsync( + TaskExecutionContext context, + ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommand command, + CancellationToken cancellationToken); + + Task> AdvanceAsync( + TaskExecutionContext context, + AdvanceWorkspaceStaffIdentityAnchorSweepCommand command, + CancellationToken cancellationToken); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence.cs new file mode 100644 index 00000000..e950b723 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence.cs @@ -0,0 +1,9 @@ +namespace BunkFy.Modules.Workspaces.Application.Ports; + +public interface IWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence +{ + Task CanMutateAsync( + string tenantId, + string? subjectId, + CancellationToken cancellationToken); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffOnboardingRestorationSuppressionReader.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffOnboardingRestorationSuppressionReader.cs new file mode 100644 index 00000000..423090ad --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffOnboardingRestorationSuppressionReader.cs @@ -0,0 +1,17 @@ +namespace BunkFy.Modules.Workspaces.Application.Ports; + +public interface IWorkspaceStaffOnboardingRestorationSuppressionReader +{ + Task ReadAsync( + string scopeId, + Guid staffMemberId, + string authSubjectId, + CancellationToken cancellationToken); +} + +public enum WorkspaceStaffOnboardingRestorationSuppressionState +{ + None = 1, + Suppressed = 2, + Conflict = 3 +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffOnboardingSerializedReadBoundary.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffOnboardingSerializedReadBoundary.cs new file mode 100644 index 00000000..724de5f1 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Ports/IWorkspaceStaffOnboardingSerializedReadBoundary.cs @@ -0,0 +1,10 @@ +namespace BunkFy.Modules.Workspaces.Application.Ports; + +using Gma.Framework.Results; + +public interface IWorkspaceStaffOnboardingSerializedReadBoundary +{ + Task> RunAsync( + Func>> read, + CancellationToken cancellationToken); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Queries/GetWorkspaceStaffIdentityAnchorCutoverStatusQuery.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Queries/GetWorkspaceStaffIdentityAnchorCutoverStatusQuery.cs new file mode 100644 index 00000000..22cb931e --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Queries/GetWorkspaceStaffIdentityAnchorCutoverStatusQuery.cs @@ -0,0 +1,7 @@ +namespace BunkFy.Modules.Workspaces.Application.Queries; + +using Gma.Framework.Cqrs; + +public sealed record GetWorkspaceStaffIdentityAnchorCutoverStatusQuery( + WorkspaceStaffIdentityAnchorOwnerManifest? OwnerManifest) + : IQuery; diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Queries/GetWorkspaceStaffIdentityAnchorSweepStatusQuery.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Queries/GetWorkspaceStaffIdentityAnchorSweepStatusQuery.cs new file mode 100644 index 00000000..48cfb360 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Queries/GetWorkspaceStaffIdentityAnchorSweepStatusQuery.cs @@ -0,0 +1,7 @@ +namespace BunkFy.Modules.Workspaces.Application.Queries; + +using BunkFy.Modules.Workspaces.Application.Models; +using Gma.Framework.Cqrs; + +public sealed record GetWorkspaceStaffIdentityAnchorSweepStatusQuery + : IQuery; diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Tasks/WorkspaceStaffIdentityAnchorSweepTaskHandler.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Tasks/WorkspaceStaffIdentityAnchorSweepTaskHandler.cs new file mode 100644 index 00000000..a048ebe0 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Tasks/WorkspaceStaffIdentityAnchorSweepTaskHandler.cs @@ -0,0 +1,589 @@ +namespace BunkFy.Modules.Workspaces.Application.Tasks; + +using System.Text.Json; +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Handlers; +using BunkFy.Modules.Workspaces.Application.Models; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Contracts; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Scoping; +using Gma.Framework.Tasks; +using SweepApplicationErrors = + BunkFy.Modules.Workspaces.Application + .WorkspaceStaffIdentityAnchorSweepErrors; + +internal sealed class WorkspaceStaffIdentityAnchorSweepTaskHandler( + IWorkspaceStaffIdentityAnchorSweepTransactionDispatcher + transactionDispatcher, + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader outcomes, + IStaffWorkspaceOnboardingIdentityAnchorResolutionRecorder recorder, + IIdGenerator ids, + IScopeContext scopeContext) + : ITaskHandler +{ + private const int MaximumCandidateTransitions = 4; + private readonly IWorkspaceStaffIdentityAnchorSweepTransactionDispatcher + transactionDispatcher = transactionDispatcher; + private readonly IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + outcomes = outcomes; + private readonly IStaffWorkspaceOnboardingIdentityAnchorResolutionRecorder + recorder = recorder; + private readonly IIdGenerator ids = ids; + private readonly IScopeContext scopeContext = scopeContext; + + public async Task HandleAsync( + ReconcileWorkspaceStaffIdentityAnchorsPayload payload, + TaskExecutionContext context, + CancellationToken cancellationToken) + { + this.Validate(payload, context); + for (int batch = 0; batch < payload.MaxBatches; batch++) + { + Result prepared = + await this.transactionDispatcher.PreparePageAsync( + context, + new( + this.NewId(), + this.NewId(), + this.NewId(), + context.RunId, + payload.BatchSize), + cancellationToken) + .ConfigureAwait(false); + if (prepared.IsFailure) + { + throw Failure(prepared.Error); + } + + WorkspaceStaffIdentityAnchorSweepPage page = prepared.Value; + ValidatePage(page, payload.BatchSize); + if (!page.AdvanceRequired) + { + return; + } + + IReadOnlyDictionary staffOutcomes = + await this.ReadOutcomesAsync( + page.Candidates, + cancellationToken) + .ConfigureAwait(false); + WorkspaceStaffIdentityAnchorSweepPageCounts counts = + await this.ProcessPageAsync( + page.Candidates, + staffOutcomes, + context, + cancellationToken) + .ConfigureAwait(false); + + Result advanced = await this.transactionDispatcher + .AdvanceAsync( + context, + new( + new WorkspaceStaffIdentityAnchorSweepAdvance( + page.CheckpointId, + page.CheckpointVersion, + page.CycleId, + page.ExpectedAfterOrdinal, + page.NextAfterOrdinal ?? + throw InvalidTask(), + page.ReachedEnd, + this.NewId(), + context.RunId, + counts)), + cancellationToken) + .ConfigureAwait(false); + if (advanced.IsFailure) + { + throw Failure(advanced.Error); + } + + if (page.ReachedEnd) + { + return; + } + } + } + + private async Task> ReadOutcomesAsync( + IReadOnlyList + candidates, + CancellationToken cancellationToken) + { + if (candidates.Count == 0) + { + return new Dictionary(); + } + + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest[] requests = + candidates.Select(candidate => + new StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest( + candidate.ApplicationId, + candidate.SubjectId)) + .ToArray(); + IReadOnlyList read = + await this.outcomes.ReadAsync(requests, cancellationToken) + .ConfigureAwait(false); + if (read.Count != requests.Length || + read.Count > + StaffWorkspaceOnboardingIdentityAnchorLifecycleLimits + .MaximumBatchSize || + read.Any(outcome => !IsDefinedOutcome(outcome)) || + read.Select(outcome => outcome.ApplicationId).Distinct().Count() != + read.Count) + { + throw Failure( + SweepApplicationErrors.StaffBatchInvalid); + } + + Dictionary byId = + read.ToDictionary(outcome => outcome.ApplicationId); + if (requests.Any(request => !byId.ContainsKey(request.ApplicationId))) + { + throw Failure( + SweepApplicationErrors.StaffBatchInvalid); + } + + return byId; + } + + private async Task + ProcessPageAsync( + IReadOnlyList + candidates, + IReadOnlyDictionary staffOutcomes, + TaskExecutionContext context, + CancellationToken cancellationToken) + { + MutableCounts counts = new(candidates.Count); + foreach (WorkspaceStaffIdentityAnchorSweepCandidate candidate in + candidates) + { + StaffWorkspaceOnboardingIdentityAnchorOutcome staffOutcome = + staffOutcomes[candidate.ApplicationId]; + if (staffOutcome.Status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Absent && + !candidate.HasLocalAnchorState) + { + counts.NoAnchorCount++; + continue; + } + + if (staffOutcome.Status is + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unknown or + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Corrupt) + { + counts.ConflictCount++; + continue; + } + + CandidateFinalOutcome final = await this.ProcessCandidateAsync( + candidate, + staffOutcome.Status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Absent && + candidate.HasLocalAnchorState, + counts, + context, + cancellationToken) + .ConfigureAwait(false); + counts.Record(final); + } + + return counts.ToImmutable(); + } + + private async Task ProcessCandidateAsync( + WorkspaceStaffIdentityAnchorSweepCandidate candidate, + bool absentLocalContradiction, + MutableCounts counts, + TaskExecutionContext context, + CancellationToken cancellationToken) + { + bool passOneCounted = false; + bool resolutionRecordCounted = false; + for (int transition = 0; + transition < MaximumCandidateTransitions; + transition++) + { + Result + reconciled = await this.transactionDispatcher + .ReconcileCandidateAsync( + context, + new(candidate.ApplicationId), + cancellationToken) + .ConfigureAwait(false); + if (reconciled.IsFailure) + { + return IsDeferred(reconciled.Error) + ? CandidateFinalOutcome.Deferred + : CandidateFinalOutcome.Conflict; + } + + WorkspaceStaffIdentityAnchorSweepCandidateResult result = + reconciled.Value; + if (result.ApplicationId != candidate.ApplicationId) + { + return CandidateFinalOutcome.Conflict; + } + + switch (result.Outcome) + { + case WorkspaceStaffIdentityAnchorSweepCandidateOutcome + .NoAnchor: + return absentLocalContradiction + ? CandidateFinalOutcome.Conflict + : CandidateFinalOutcome.NoAnchor; + case WorkspaceStaffIdentityAnchorSweepCandidateOutcome.Removed: + return CandidateFinalOutcome.Removed; + case WorkspaceStaffIdentityAnchorSweepCandidateOutcome + .ObservedNow: + return CandidateFinalOutcome.Observed; + case WorkspaceStaffIdentityAnchorSweepCandidateOutcome + .AlreadyObserved: + return CandidateFinalOutcome.AlreadyObserved; + case WorkspaceStaffIdentityAnchorSweepCandidateOutcome + .PassOneCommitted: + if (!passOneCounted) + { + counts.PassOneCommittedCount++; + passOneCounted = true; + } + + continue; + case WorkspaceStaffIdentityAnchorSweepCandidateOutcome + .ResolutionReadyToRecord: + if (!IsValidRequest( + candidate.ApplicationId, + result.ResolutionRequest)) + { + return CandidateFinalOutcome.Conflict; + } + + StaffWorkspaceOnboardingIdentityAnchorResolutionResult + recorded = await this.recorder.RecordAsync( + result.ResolutionRequest!, + cancellationToken) + .ConfigureAwait(false); + if (recorded.Status is not ( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus + .Recorded or + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus + .AlreadyRecorded)) + { + return CandidateFinalOutcome.Conflict; + } + + if (!resolutionRecordCounted) + { + counts.ResolutionRecordConfirmedCount++; + resolutionRecordCounted = true; + } + + continue; + case WorkspaceStaffIdentityAnchorSweepCandidateOutcome.Unknown: + return CandidateFinalOutcome.Conflict; + default: + return CandidateFinalOutcome.Conflict; + } + } + + return CandidateFinalOutcome.Deferred; + } + + private static bool IsValidRequest( + Guid expectedApplicationId, + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest? request) => + request is not null && + request.ApplicationId == expectedApplicationId && + request.ApplicationId != Guid.Empty && + request.StaffMemberId != Guid.Empty && + request.ResolutionEventId != Guid.Empty && + request.ResolutionEventId != request.ApplicationId && + request.WorkspaceApplicationVersion > 0 && + request.ResolvedAtUtc != default && + request.Disposition != + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .Unknown; + + private static bool IsDefinedOutcome( + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome) => + outcome.ApplicationId != Guid.Empty && + Enum.IsDefined(outcome.Status) && + Enum.IsDefined(outcome.TargetLifecycle) && + Enum.IsDefined(outcome.SubjectMatch) && + (!outcome.ResolutionDisposition.HasValue || + Enum.IsDefined(outcome.ResolutionDisposition.Value)) && + outcome.Status switch + { + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Absent => + !outcome.StaffMemberId.HasValue && + outcome.TargetLifecycle == + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Unknown && + outcome.SubjectMatch == + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Unknown && + !outcome.WorkspaceApplicationVersion.HasValue && + !outcome.ResolutionDisposition.HasValue && + !outcome.ResolutionEventId.HasValue, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved => + IsConcreteAnchor(outcome) && + !outcome.WorkspaceApplicationVersion.HasValue && + !outcome.ResolutionDisposition.HasValue, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved => + IsConcreteAnchor(outcome) && + outcome.WorkspaceApplicationVersion is > 0 && + outcome.ResolutionDisposition is not null and not + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .Unknown, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unknown or + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Corrupt => + true, + _ => false + }; + + private static bool IsConcreteAnchor( + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome) => + outcome.StaffMemberId is { } staffMemberId && + staffMemberId != Guid.Empty && + outcome.ResolutionEventId is { } resolutionEventId && + resolutionEventId != Guid.Empty && + outcome.TargetLifecycle != + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Unknown && + outcome.SubjectMatch != + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Unknown; + + private static bool IsDeferred(Error error) => + string.Equals( + error.Code, + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorLifecycleTransitionPending.Code, + StringComparison.Ordinal) || + string.Equals( + error.Code, + WorkspaceStaffOnboardingApplicationErrors + .ProcessingRestricted.Code, + StringComparison.Ordinal) || + string.Equals( + error.Code, + WorkspaceStaffOnboardingApplicationErrors + .RestrictionProjectionUnavailable.Code, + StringComparison.Ordinal) || + string.Equals( + error.Code, + WorkspaceOperationalAdmissionErrors.ProcessingRestricted.Code, + StringComparison.Ordinal) || + string.Equals( + error.Code, + WorkspaceOperationalAdmissionErrors.AdmissionUnavailable.Code, + StringComparison.Ordinal); + + private Guid NewId() + { + for (int attempt = 0; attempt < 8; attempt++) + { + Guid id = this.ids.NewId(); + if (id != Guid.Empty) + { + return id; + } + } + + throw InvalidTask(); + } + + private void Validate( + ReconcileWorkspaceStaffIdentityAnchorsPayload payload, + TaskExecutionContext context) + { + if (payload.BatchSize is <= 0 or > + ReconcileWorkspaceStaffIdentityAnchorsPayload + .MaximumBatchSize || + payload.BatchSize > + StaffWorkspaceOnboardingIdentityAnchorLifecycleLimits + .MaximumBatchSize || + payload.MaxBatches is <= 0 or > + ReconcileWorkspaceStaffIdentityAnchorsPayload + .MaximumBatches || + !WorkspaceStaffIdentityAnchorTenantScope.TryGetCanonicalTenantId( + this.scopeContext, + out string tenantId) || + !string.Equals( + context.ScopeId, + tenantId, + StringComparison.Ordinal)) + { + throw InvalidTask(); + } + } + + private static void ValidatePage( + WorkspaceStaffIdentityAnchorSweepPage page, + int batchSize) + { + bool candidatesOrdered = AreStrictlyOrdered( + page.Candidates.Select(candidate => + candidate.IdentityAnchorSweepOrdinal)); + bool emptyCompletedCycle = + !page.AdvanceRequired && + page.ReachedEnd && + page.Candidates.Count == 0 && + !page.UpperOrdinal.HasValue && + !page.ExpectedAfterOrdinal.HasValue && + !page.NextAfterOrdinal.HasValue; + bool advanceablePage = + page.AdvanceRequired && + page.CheckpointId != Guid.Empty && + page.CheckpointVersion > 0 && + page.CycleId != Guid.Empty && + page.UpperOrdinal is > 0 && + page.NextAfterOrdinal is > 0 && + page.NextAfterOrdinal <= page.UpperOrdinal && + (!page.ExpectedAfterOrdinal.HasValue || + (page.ExpectedAfterOrdinal > 0 && + page.ExpectedAfterOrdinal < page.NextAfterOrdinal)) && + page.Candidates.Count <= batchSize && + page.Candidates.All(candidate => + candidate.ApplicationId != Guid.Empty && + candidate.IdentityAnchorSweepOrdinal > 0 && + candidate.IdentityAnchorSweepOrdinal <= page.UpperOrdinal && + (!page.ExpectedAfterOrdinal.HasValue || + candidate.IdentityAnchorSweepOrdinal > + page.ExpectedAfterOrdinal) && + !string.IsNullOrWhiteSpace(candidate.SubjectId)) && + candidatesOrdered && + (page.Candidates.Count == 0 + ? page.ReachedEnd && + page.NextAfterOrdinal == page.UpperOrdinal + : page.NextAfterOrdinal == page.Candidates[^1] + .IdentityAnchorSweepOrdinal) && + page.Candidates.Select(candidate => candidate.ApplicationId) + .Distinct().Count() == page.Candidates.Count; + if (!emptyCompletedCycle && !advanceablePage) + { + throw InvalidTask(); + } + } + + private static bool AreStrictlyOrdered(IEnumerable ordinals) + { + long? previous = null; + foreach (long ordinal in ordinals) + { + if (previous.HasValue && ordinal <= previous.Value) + { + return false; + } + + previous = ordinal; + } + + return true; + } + + private static InvalidOperationException Failure(Error error) => + new($"{error.Code}: {error.Message}"); + + private static InvalidOperationException InvalidTask() => + Failure(SweepApplicationErrors.InvalidTask); + + private enum CandidateFinalOutcome + { + NoAnchor, + Removed, + Observed, + AlreadyObserved, + Deferred, + Conflict + } + + private sealed class MutableCounts(int scannedCount) + { + public long ScannedCount { get; } = scannedCount; + public long NoAnchorCount { get; set; } + public long RemovedCount { get; set; } + public long ObservedCount { get; set; } + public long AlreadyObservedCount { get; set; } + public long DeferredCount { get; set; } + public long ConflictCount { get; set; } + public long PassOneCommittedCount { get; set; } + public long ResolutionRecordConfirmedCount { get; set; } + + public void Record(CandidateFinalOutcome outcome) + { + switch (outcome) + { + case CandidateFinalOutcome.NoAnchor: + this.NoAnchorCount++; + break; + case CandidateFinalOutcome.Removed: + this.RemovedCount++; + break; + case CandidateFinalOutcome.Observed: + this.ObservedCount++; + break; + case CandidateFinalOutcome.AlreadyObserved: + this.AlreadyObservedCount++; + break; + case CandidateFinalOutcome.Deferred: + this.DeferredCount++; + break; + case CandidateFinalOutcome.Conflict: + this.ConflictCount++; + break; + default: + throw InvalidTask(); + } + } + + public WorkspaceStaffIdentityAnchorSweepPageCounts ToImmutable() => + new( + this.ScannedCount, + this.NoAnchorCount, + this.RemovedCount, + this.ObservedCount, + this.AlreadyObservedCount, + this.DeferredCount, + this.ConflictCount, + this.PassOneCommittedCount, + this.ResolutionRecordConfirmedCount); + } +} + +internal sealed class WorkspaceStaffIdentityAnchorSweepScheduleProvider( + IWorkspaceStaffIdentityAnchorSweepRepository repository) + : ITaskScheduleProvider +{ + public async IAsyncEnumerable GetSchedulesAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] + CancellationToken cancellationToken) + { + await foreach (string scopeId in repository + .StreamScheduleScopeIdsAsync(cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + yield return new( + "staff-identity-anchor-sweep", + WorkspacesModuleMetadata.Name, + ReconcileWorkspaceStaffIdentityAnchorsPayload.TaskName, + JsonSerializer.Serialize( + new ReconcileWorkspaceStaffIdentityAnchorsPayload()), + TimeSpan.FromMinutes(5), + ReconcileWorkspaceStaffIdentityAnchorsPayload.WorkerGroup, + scopeId, + maxAttempts: 5, + ReconcileWorkspaceStaffIdentityAnchorsPayload.PayloadVersion, + runOnStart: true); + } + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Tasks/WorkspaceStaffIdentityAnchorSweepTransactionDispatcher.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Tasks/WorkspaceStaffIdentityAnchorSweepTransactionDispatcher.cs new file mode 100644 index 00000000..0d99870b --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/Tasks/WorkspaceStaffIdentityAnchorSweepTransactionDispatcher.cs @@ -0,0 +1,105 @@ +namespace BunkFy.Modules.Workspaces.Application.Tasks; + +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Handlers; +using BunkFy.Modules.Workspaces.Application.Models; +using BunkFy.Modules.Workspaces.Application.Ports; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Scoping; +using Gma.Framework.Tasks; +using Gma.Framework.Tasks.Cqrs; +using Microsoft.Extensions.DependencyInjection; + +internal sealed class WorkspaceStaffIdentityAnchorSweepTransactionDispatcher( + IServiceScopeFactory scopeFactory) + : IWorkspaceStaffIdentityAnchorSweepTransactionDispatcher +{ + public Task> + PreparePageAsync( + TaskExecutionContext context, + PrepareWorkspaceStaffIdentityAnchorSweepPageCommand command, + CancellationToken cancellationToken) => + this.DispatchInFreshScopeAsync< + PrepareWorkspaceStaffIdentityAnchorSweepPageCommand, + WorkspaceStaffIdentityAnchorSweepPage>( + context, + command, + cancellationToken); + + public Task> + ReconcileCandidateAsync( + TaskExecutionContext context, + ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommand command, + CancellationToken cancellationToken) => + this.DispatchInFreshScopeAsync< + ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommand, + WorkspaceStaffIdentityAnchorSweepCandidateResult>( + context, + command, + cancellationToken); + + public Task> AdvanceAsync( + TaskExecutionContext context, + AdvanceWorkspaceStaffIdentityAnchorSweepCommand command, + CancellationToken cancellationToken) => + this.DispatchInFreshScopeAsync< + AdvanceWorkspaceStaffIdentityAnchorSweepCommand, + Unit>( + context, + command, + cancellationToken); + + private async Task> DispatchInFreshScopeAsync< + TCommand, + TResponse>( + TaskExecutionContext context, + TCommand command, + CancellationToken cancellationToken) + where TCommand : ICommand + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(command); + + await using AsyncServiceScope scope = + scopeFactory.CreateAsyncScope(); + IScopeContextAccessor scopeContext = scope.ServiceProvider + .GetRequiredService(); + if (string.IsNullOrWhiteSpace(context.ScopeId)) + { + return Invalid(); + } + + scopeContext.SetScope(context.ScopeId); + try + { + if (!WorkspaceStaffIdentityAnchorTenantScope + .TryGetCanonicalTenantId( + scopeContext, + out string tenantId) || + !string.Equals( + tenantId, + context.ScopeId, + StringComparison.Ordinal)) + { + return Invalid(); + } + + return await scope.ServiceProvider + .GetRequiredService() + .DispatchAsync( + context, + command, + cancellationToken) + .ConfigureAwait(false); + } + finally + { + scopeContext.ClearScope(); + } + } + + private static Result Invalid() => + Result.Failure( + WorkspaceStaffIdentityAnchorSweepErrors.InvalidTask); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffCorrelationAnonymisationApplicationErrors.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffCorrelationAnonymisationApplicationErrors.cs index 8d19d51b..ff7cf953 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffCorrelationAnonymisationApplicationErrors.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffCorrelationAnonymisationApplicationErrors.cs @@ -37,6 +37,10 @@ public static class "Workspaces.StaffCorrelationAnonymisationBlocked.NotEligible", "The workspace Staff correlation is not eligible for anonymisation."); + public static readonly Error IdentityAnchorUnavailable = new( + "Workspaces.StaffCorrelationAnonymisationBlocked.IdentityAnchorUnavailable", + "Workspace Staff identity-anchor resolution must be exact and observed before correlation anonymisation."); + public static readonly Error RestoreRequestInvalid = new( "Workspaces.StaffCorrelationAnonymisationRestoreRequestInvalid", "The workspace Staff correlation anonymisation restore request is invalid."); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffHistoricalNoProvisionApplicationErrors.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffHistoricalNoProvisionApplicationErrors.cs new file mode 100644 index 00000000..60466bed --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffHistoricalNoProvisionApplicationErrors.cs @@ -0,0 +1,22 @@ +namespace BunkFy.Modules.Workspaces.Application; + +using Gma.Framework.Results; + +public static class WorkspaceStaffHistoricalNoProvisionApplicationErrors +{ + public static readonly Error TenantRequired = new( + "Workspaces.StaffHistoricalNoProvisionTenantRequired", + "A canonical tenant scope is required."); + + public static readonly Error RequestInvalid = new( + "Workspaces.StaffHistoricalNoProvisionRequestInvalid", + "The historical no-provision review request is invalid."); + + public static readonly Error Conflict = new( + "Workspaces.StaffHistoricalNoProvisionConflict", + "The historical no-provision evidence conflicts with current authority."); + + public static readonly Error ExternalEvidenceUnavailable = new( + "Workspaces.StaffHistoricalNoProvisionEvidenceUnavailable", + "Historical no-provision authority evidence is unavailable."); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffHistoricalNoProvisionModels.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffHistoricalNoProvisionModels.cs new file mode 100644 index 00000000..024acc3d --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffHistoricalNoProvisionModels.cs @@ -0,0 +1,14 @@ +namespace BunkFy.Modules.Workspaces.Application; + +using BunkFy.Modules.Workspaces.Domain; + +public sealed record WorkspaceStaffHistoricalNoProvisionDispositionResult( + Guid ReceiptId, + Guid OperationId, + Guid ApplicationId, + long ResultApplicationVersion, + WorkspaceStaffOnboardingState ResultApplicationStatus, + string StaffEvidenceSha256, + string CanonicalSha256, + DateTimeOffset ReviewedAtUtc, + bool AlreadyReviewed); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffIdentityAnchorCutoverErrors.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffIdentityAnchorCutoverErrors.cs new file mode 100644 index 00000000..1763dce8 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffIdentityAnchorCutoverErrors.cs @@ -0,0 +1,46 @@ +namespace BunkFy.Modules.Workspaces.Application; + +using Gma.Framework.Results; + +public static class WorkspaceStaffIdentityAnchorCutoverErrors +{ + public static readonly Error TenantRequired = new( + "Workspaces.IdentityAnchorCutoverTenantRequired", + "A tenant context is required for identity-anchor cutover."); + public static readonly Error RequestInvalid = new( + "Workspaces.IdentityAnchorCutoverRequestInvalid", + "The identity-anchor cutover request is invalid."); + public static readonly Error OwnerManifestRequired = new( + "Workspaces.IdentityAnchorCutoverOwnerManifestRequired", + "An explicitly reviewed owner-membership binding manifest is required."); + public static readonly Error OwnerManifestInvalid = new( + "Workspaces.IdentityAnchorCutoverOwnerManifestInvalid", + "The owner-membership binding manifest is invalid for this tenant."); + public static readonly Error SourceEvidenceChanged = new( + "Workspaces.IdentityAnchorCutoverSourceEvidenceChanged", + "The Workspaces source evidence changed after operator review."); + public static readonly Error AnchorStateChanged = new( + "Workspaces.IdentityAnchorCutoverAnchorStateChanged", + "The Staff identity-anchor state changed after operator review."); + public static readonly Error SourcePageInvalid = new( + "Workspaces.IdentityAnchorCutoverSourcePageInvalid", + "The Workspaces identity-anchor source page did not advance deterministically."); + public static readonly Error OwnerManifestChanged = new( + "Workspaces.IdentityAnchorCutoverOwnerManifestChanged", + "The owner-membership binding manifest digest does not match operator review."); + public static readonly Error Blocked = new( + "Workspaces.IdentityAnchorCutoverBlocked", + "Ambiguous, conflicting, or unproven identity evidence blocks reconciliation."); + public static readonly Error StaffUnavailable = new( + "Workspaces.IdentityAnchorCutoverStaffUnavailable", + "Staff identity-anchor inspection or reconciliation is unavailable."); + public static readonly Error ApplyOutcomeUnknown = new( + "Workspaces.IdentityAnchorCutoverApplyOutcomeUnknown", + "The Staff identity-anchor apply outcome is indeterminate; archive the output and rebuild status before further reconciliation."); + public static readonly Error OrganizationsUnavailable = new( + "Workspaces.IdentityAnchorCutoverOrganizationsUnavailable", + "Revision-fenced Organizations identity evidence is unavailable."); + public static readonly Error OrganizationsEvidenceChanged = new( + "Workspaces.IdentityAnchorCutoverOrganizationsEvidenceChanged", + "Organizations identity evidence changed while the cutover plan was being built."); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffIdentityAnchorCutoverModels.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffIdentityAnchorCutoverModels.cs new file mode 100644 index 00000000..bf027486 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffIdentityAnchorCutoverModels.cs @@ -0,0 +1,89 @@ +namespace BunkFy.Modules.Workspaces.Application; + +using BunkFy.Modules.Staff.Contracts; + +public sealed record WorkspaceStaffIdentityAnchorOwnerManifest( + int ContractVersion, + string TenantId, + DateTimeOffset ReviewedAtUtc, + WorkspaceStaffIdentityAnchorHistoricalEvidence HistoricalEvidence, + IReadOnlyList Bindings); + +public sealed record WorkspaceStaffIdentityAnchorHistoricalEvidence( + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind Kind, + string EvidenceSha256); + +public sealed record WorkspaceStaffIdentityAnchorOwnerBinding( + Guid OrganizationId, + string ScopeId, + Guid MembershipId, + Guid StaffMemberId, + long ObservedMembershipVersion, + WorkspaceStaffIdentityAnchorOwnerBindingEvidenceKind EvidenceKind, + bool ReviewedErasedTarget = false); + +public enum WorkspaceStaffIdentityAnchorHistoricalEvidenceKind +{ + Unknown = 0, + ReviewedHistoricalOwnerUniverse = 1, + ReviewedNoHistoricalOwnerSources = 2 +} + +public enum WorkspaceStaffIdentityAnchorOwnerBindingEvidenceKind +{ + Unknown = 0, + CurrentActiveOwnerReview = 1, + HistoricalOwnerExternalReview = 2 +} + +public static class WorkspaceStaffIdentityAnchorCutoverStatusLimits +{ + public const int MaximumIssues = 100; +} + +public sealed record WorkspaceStaffIdentityAnchorCutoverIssue( + StaffIdentityProvisioningAnchorSourceKind SourceKind, + Guid SourceId, + StaffIdentityProvisioningAnchorCutoverDisposition Disposition, + long? ObservedMembershipVersion, + Guid? CandidateStaffMemberId); + +public sealed record WorkspaceStaffIdentityAnchorCutoverStatus( + long WorkspaceSourceCount, + long OwnerBindingCount, + long AlreadyAnchoredCount, + long SeedableWorkspaceCount, + long SeedableOwnerCount, + long AmbiguousCount, + long ConflictCount, + string SourceEvidenceSha256, + string AnchorStateSha256, + string? OwnerManifestSha256, + bool OwnerManifestProvided, + bool CanReconcile, + bool IsReady, + long AuthoritativeOwnerCount, + long OrganizationsScopeRevision, + long HistoricalBindingCount, + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + HistoricalEvidenceKind, + string? HistoricalEvidenceSha256, + long TotalIssueCount, + bool HasMoreIssues, + IReadOnlyList Issues); + +public sealed record WorkspaceStaffIdentityAnchorReconcileResult( + int? AppliedCount, + WorkspaceStaffIdentityAnchorCutoverStatus? Status, + WorkspaceStaffIdentityAnchorReconcileOutcome Outcome, + bool MustRerunStatus, + string AcceptedSourceEvidenceSha256, + string AcceptedAnchorStateSha256, + string AcceptedOwnerManifestSha256); + +public enum WorkspaceStaffIdentityAnchorReconcileOutcome +{ + Unknown = 0, + AppliedAndVerified = 1, + ApplyOutcomeUnknown = 2 +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffIdentityAnchorSweepErrors.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffIdentityAnchorSweepErrors.cs new file mode 100644 index 00000000..0b8d9474 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffIdentityAnchorSweepErrors.cs @@ -0,0 +1,14 @@ +namespace BunkFy.Modules.Workspaces.Application; + +using Gma.Framework.Results; + +public static class WorkspaceStaffIdentityAnchorSweepErrors +{ + public static readonly Error InvalidTask = new( + "Workspaces.IdentityAnchorSweepTaskInvalid", + "The identity-anchor sweep task coordinates are invalid."); + + public static readonly Error StaffBatchInvalid = new( + "Workspaces.IdentityAnchorSweepStaffBatchInvalid", + "Staff returned an invalid identity-anchor outcome batch."); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffOnboardingApplicationErrors.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffOnboardingApplicationErrors.cs index 40163cfd..8fe3071f 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."); @@ -68,4 +71,13 @@ public static class WorkspaceStaffOnboardingApplicationErrors public static readonly Error ProcessingRestricted = new( "Workspaces.StaffOnboardingProcessingRestricted", "The selected Staff onboarding record is restricted from ordinary processing."); + public static readonly Error IdentityAnchorConflict = new( + "Workspaces.StaffOnboardingIdentityAnchorConflict", + "The Staff identity-anchor outcome conflicts with the onboarding record."); + public static readonly Error IdentityAnchorSuspended = new( + "Workspaces.StaffOnboardingIdentityAnchorSuspended", + "The anchored Staff member is suspended and cannot receive workspace access."); + public static readonly Error IdentityAnchorLifecycleTransitionPending = new( + "Workspaces.StaffOnboardingIdentityAnchorLifecycleTransitionPending", + "The anchored Staff member has an unfinished workspace access lifecycle transition."); } diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffOnboardingSubmitter.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffOnboardingSubmitter.cs index 1287239d..25d4124f 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffOnboardingSubmitter.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Application/WorkspaceStaffOnboardingSubmitter.cs @@ -8,6 +8,11 @@ namespace BunkFy.Modules.Workspaces.Application; public interface IWorkspaceStaffOnboardingSubmitter { + Task> + SubmitWithAuthorityOutcomeAsync( + SubmitWorkspaceStaffOnboardingCommand command, + CancellationToken cancellationToken = default); + Task> SubmitAsync( SubmitWorkspaceStaffOnboardingCommand command, CancellationToken cancellationToken = default); @@ -21,6 +26,18 @@ internal sealed class WorkspaceStaffOnboardingSubmitter( public async Task> SubmitAsync( SubmitWorkspaceStaffOnboardingCommand command, CancellationToken cancellationToken = default) + { + Result outcome = + await this.SubmitWithAuthorityOutcomeAsync( + command, + cancellationToken).ConfigureAwait(false); + return Map(outcome); + } + + public async Task> + SubmitWithAuthorityOutcomeAsync( + SubmitWorkspaceStaffOnboardingCommand command, + CancellationToken cancellationToken = default) { WorkspaceStaffJoinTokenAuthority? authority = await authorityResolver.ResolveAsync( command.SourceKind, @@ -28,7 +45,7 @@ public async Task> SubmitAsync( cancellationToken).ConfigureAwait(false); if (!authority.HasValue) { - return Result.Failure( + return Result.Failure( WorkspaceStaffOnboardingApplicationErrors.JoinTokenInvalid); } @@ -47,7 +64,8 @@ await services Result admitted = WorkspaceOperationalAdmissionGuard.RequireAllowed(decision); return admitted.IsFailure - ? Result.Failure( + ? Result.Failure< + WorkspaceStaffOnboardingSubmissionOutcome>( admitted.Error) : await services .GetRequiredService() @@ -55,4 +73,29 @@ await services .ConfigureAwait(false); }).ConfigureAwait(false); } + + internal static Result Map( + Result outcome) + { + if (outcome.IsFailure) + { + return Result.Failure(outcome.Error); + } + + return outcome.Value.Kind switch + { + WorkspaceStaffOnboardingSubmissionOutcomeKind.Applied + when outcome.Value.Application is not null => + Result.Success(outcome.Value.Application), + WorkspaceStaffOnboardingSubmissionOutcomeKind + .AuthorityMovedToStaff + when outcome.Value.Application is null => + Result.Failure( + WorkspaceStaffOnboardingApplicationErrors + .ProfileMutationAuthorityUnavailable), + _ => Result.Failure( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorConflict) + }; + } } diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/Events/WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/Events/WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent.cs new file mode 100644 index 00000000..2ba4c985 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/Events/WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent.cs @@ -0,0 +1,46 @@ +namespace BunkFy.Modules.Workspaces.Contracts; + +using Gma.Framework.Messaging; +using Gma.Framework.Naming; +using Gma.Framework.Scoping; + +[IntegrationEventName(EventType)] +[IntegrationEventVersion(EventVersion)] +[ScopeAware] +public sealed record + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + : IntegrationEvent, IScopedIntegrationEvent +{ + public const string EventType = + "workspace-staff-onboarding-identity-anchor-continuation-requested"; + public const int EventVersion = 1; + + public WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent( + Guid eventId, + string scopeId, + DateTimeOffset occurredAtUtc, + Guid applicationId, + Guid staffMemberId) + : base(eventId, occurredAtUtc, EventType, EventVersion) + { + this.ScopeId = ScopeIds.Normalize(scopeId, nameof(scopeId)); + this.ApplicationId = IntegrationEventContractGuards.RequireId( + applicationId, + nameof(applicationId)); + this.StaffMemberId = IntegrationEventContractGuards.RequireId( + staffMemberId, + nameof(staffMemberId)); + if (eventId == this.ApplicationId) + { + throw new ArgumentException( + "The continuation event id must be distinct from its application coordinate.", + nameof(eventId)); + } + } + + public string ScopeId { get; } + public Guid ApplicationId { get; } + public Guid StaffMemberId { get; } + string IScopedIntegrationEvent.ScopeId => this.ScopeId; + +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/Events/WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/Events/WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition.cs new file mode 100644 index 00000000..2c7a33c6 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/Events/WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition.cs @@ -0,0 +1,11 @@ +namespace BunkFy.Modules.Workspaces.Contracts; + +public enum WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition +{ + Unknown = 0, + CompletedRedacted = 1, + RejectedRedacted = 2, + SupersededRedacted = 3, + ExpiredRedacted = 4, + WithdrawnRedacted = 5 +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/Events/WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/Events/WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent.cs new file mode 100644 index 00000000..d8cdcf5b --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/Events/WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent.cs @@ -0,0 +1,62 @@ +namespace BunkFy.Modules.Workspaces.Contracts; + +using BunkFy.Modules.Staff.Contracts; +using Gma.Framework.Messaging; +using Gma.Framework.Naming; +using Gma.Framework.Scoping; + +[IntegrationEventName(EventType)] +[IntegrationEventVersion(EventVersion)] +[ScopeAware] +public sealed record + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent + : IntegrationEvent, IScopedIntegrationEvent +{ + public const string EventType = + "workspace-staff-onboarding-identity-anchor-resolved"; + public const int EventVersion = 1; + + public WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent( + Guid eventId, + string scopeId, + DateTimeOffset occurredAtUtc, + Guid applicationId, + Guid staffMemberId, + long workspaceApplicationVersion, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition disposition) + : base(eventId, occurredAtUtc, EventType, EventVersion) + { + this.ScopeId = ScopeIds.Normalize(scopeId, nameof(scopeId)); + this.ApplicationId = IntegrationEventContractGuards.RequireId( + applicationId, + nameof(applicationId)); + this.StaffMemberId = IntegrationEventContractGuards.RequireId( + staffMemberId, + nameof(staffMemberId)); + if (eventId == this.ApplicationId) + { + throw new ArgumentException( + "The resolution event id must be distinct from its application coordinate.", + nameof(eventId)); + } + + this.WorkspaceApplicationVersion = workspaceApplicationVersion > 0 + ? workspaceApplicationVersion + : throw new ArgumentOutOfRangeException( + nameof(workspaceApplicationVersion)); + this.Disposition = Enum.IsDefined(disposition) && + disposition != + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .Unknown + ? disposition + : throw new ArgumentOutOfRangeException(nameof(disposition)); + } + + public string ScopeId { get; } + public Guid ApplicationId { get; } + public Guid StaffMemberId { get; } + public long WorkspaceApplicationVersion { get; } + public WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition Disposition { get; } + string IScopedIntegrationEvent.ScopeId => this.ScopeId; + +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/Tasks/ReconcileWorkspaceStaffIdentityAnchorsPayload.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/Tasks/ReconcileWorkspaceStaffIdentityAnchorsPayload.cs new file mode 100644 index 00000000..9c9b20c6 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/Tasks/ReconcileWorkspaceStaffIdentityAnchorsPayload.cs @@ -0,0 +1,27 @@ +namespace BunkFy.Modules.Workspaces.Contracts; + +using Gma.Framework.Scoping; +using Gma.Framework.Tasks; + +[TaskName(TaskName)] +[TaskPayloadVersion(PayloadVersion)] +[TaskDescription( + "Reconcile migration-backfilled Staff identity anchors with Workspaces onboarding records.")] +[TaskKind(ModuleTaskKind.Recurring)] +[TaskWorkerGroup(WorkerGroup)] +[SupportsTaskControl] +[ScopeAware] +public sealed record ReconcileWorkspaceStaffIdentityAnchorsPayload( + int BatchSize = ReconcileWorkspaceStaffIdentityAnchorsPayload + .DefaultBatchSize, + int MaxBatches = ReconcileWorkspaceStaffIdentityAnchorsPayload + .DefaultMaxBatches) : ITaskPayload +{ + public const string TaskName = "reconcile-staff-identity-anchors"; + public const string WorkerGroup = "workspaces-maintenance-workers"; + public const int PayloadVersion = 1; + public const int DefaultBatchSize = 100; + public const int MaximumBatchSize = 500; + public const int DefaultMaxBatches = 10; + public const int MaximumBatches = 100; +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspaceStaffAccessProcessDto.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspaceStaffAccessProcessDto.cs index b2cc911a..1c09dfc7 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspaceStaffAccessProcessDto.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspaceStaffAccessProcessDto.cs @@ -5,6 +5,7 @@ public sealed record WorkspaceStaffAccessProcessDto( Guid OrganizationId, Guid StaffMemberId, WorkspaceStaffAccessTargetStatus TargetStatus, + WorkspaceStaffAccessRestorationDisposition RestorationDisposition, long TargetStaffVersion, DateOnly EffectiveOn, WorkspaceStaffAccessProcessStatus Status, @@ -37,3 +38,11 @@ public enum WorkspaceStaffAccessProcessStatus RestorationPending = 3, Completed = 4 } + +public enum WorkspaceStaffAccessRestorationDisposition +{ + Unknown = 0, + NotApplicable = 1, + RestoreSnapshot = 2, + Suppressed = 3 +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspaceStaffOnboardingDto.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspaceStaffOnboardingDto.cs index 04dd8326..88a4d3f5 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspaceStaffOnboardingDto.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspaceStaffOnboardingDto.cs @@ -24,7 +24,20 @@ public sealed record WorkspaceStaffOnboardingDto( DateTimeOffset LastChangedAtUtc); public sealed record WorkspaceStaffOnboardingListResponse( - IReadOnlyList Items, + IReadOnlyList Items, int Page, int PageSize, bool HasMore); + +public sealed record WorkspaceStaffOnboardingActionableSummaryDto( + Guid ApplicationId, + WorkspaceStaffOnboardingSourceKind SourceKind, + Guid SourceId, + Guid? ClaimId, + long? ClaimVersion, + WorkspaceStaffOnboardingStatus Status, + long Version, + string? FailureCode, + DateTimeOffset CreatedAtUtc, + DateTimeOffset LastChangedAtUtc, + bool HasStaffTarget); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspacesModuleMetadata.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspacesModuleMetadata.cs index 92bc2f60..a0eaeecc 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspacesModuleMetadata.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspacesModuleMetadata.cs @@ -30,6 +30,10 @@ public static class WorkspacesModuleMetadata public const string PropertyRetiredHandlerName = "workspace-property-retired"; public const string StaffOnboardingRestrictionRecoveryHandlerName = "staff-onboarding-restriction-release-recovery"; + public const string StaffOnboardingIdentityAnchorCreatedHandlerName = + "staff-onboarding-identity-anchor-created"; + public const string StaffOnboardingIdentityAnchorContinuationHandlerName = + "staff-onboarding-identity-anchor-continuation"; public const string PropertiesProjectionName = "properties"; public const int PropertiesProjectionVersion = 1; public const string ProjectionWorkerGroup = "projection-workers"; @@ -84,11 +88,23 @@ public static class WorkspacesModuleMetadata WorkspaceStaffOnboardingProcessingRestrictionChangedIntegrationEvent>( Name, StaffOnboardingRestrictionRecoveryHandlerName) + .WithSubscription( + StaffModuleMetadata.Name, + StaffOnboardingIdentityAnchorCreatedHandlerName) + .WithSubscription< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent>( + Name, + StaffOnboardingIdentityAnchorContinuationHandlerName) .WithPublishedEvent< DataRightsTenantCorrectionAppliedIntegrationEvent>() .WithPublishedEvent< WorkspaceStaffOnboardingProcessingRestrictionChangedIntegrationEvent>() + .WithPublishedEvent< + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent>() + .WithPublishedEvent< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent>() .WithTask() + .WithTask() .WithProfile(WorkspacesProfiles.Default) .Build(); } diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspacesTenantTerminationMetadata.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspacesTenantTerminationMetadata.cs index 7c285a4d..da31ee62 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspacesTenantTerminationMetadata.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Contracts/WorkspacesTenantTerminationMetadata.cs @@ -8,11 +8,11 @@ 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 = 15; + public const int PersonalDataCatalogVersion = 15; public const string ExportSchemaId = "workspaces.tenant-termination-export"; - public const int ExportSchemaVersion = 1; + public const int ExportSchemaVersion = 4; public static IReadOnlyList DestroyDependencyOwnerKeys { get; } = Array.AsReadOnly( diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/Events/WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/Events/WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent.cs new file mode 100644 index 00000000..336056c4 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/Events/WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent.cs @@ -0,0 +1,12 @@ +namespace BunkFy.Modules.Workspaces.Domain.Events; + +using Gma.Framework.Domain; + +public sealed record + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent( + Guid EventId, + DateTimeOffset OccurredAtUtc, + string ScopeId, + Guid ApplicationId, + Guid StaffMemberId) + : ScopedDomainEvent(EventId, OccurredAtUtc, ScopeId); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/Events/WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/Events/WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent.cs new file mode 100644 index 00000000..e1ba4d28 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/Events/WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent.cs @@ -0,0 +1,13 @@ +namespace BunkFy.Modules.Workspaces.Domain.Events; + +using Gma.Framework.Domain; + +public sealed record WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent( + Guid EventId, + DateTimeOffset OccurredAtUtc, + string ScopeId, + Guid ApplicationId, + Guid StaffMemberId, + long WorkspaceApplicationVersion, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition Disposition) + : ScopedDomainEvent(EventId, OccurredAtUtc, ScopeId); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffAccessProcess.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffAccessProcess.cs index 35fb1264..50b89859 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffAccessProcess.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffAccessProcess.cs @@ -18,6 +18,11 @@ private WorkspaceStaffAccessProcess(Guid id, string scopeId) : base(id, scopeId) public Guid StaffMemberId { get; private set; } public string SubjectId { get; private set; } = string.Empty; public WorkspaceStaffAccessTargetState TargetState { get; private set; } + public WorkspaceStaffAccessRestorationDisposition RestorationDisposition + { + get; + private set; + } public long TargetStaffVersion { get; private set; } public DateOnly EffectiveOn { get; private set; } public string RequestedBy { get; private set; } = string.Empty; @@ -40,6 +45,32 @@ public static Result Create( DateOnly effectiveOn, string requestedBy, IReadOnlyCollection profileTargets, + DateTimeOffset nowUtc) => Create( + id, + scopeId, + staffMemberId, + subjectId, + targetState, + targetState == WorkspaceStaffAccessTargetState.Active + ? WorkspaceStaffAccessRestorationDisposition.RestoreSnapshot + : WorkspaceStaffAccessRestorationDisposition.NotApplicable, + targetStaffVersion, + effectiveOn, + requestedBy, + profileTargets, + nowUtc); + + public static Result Create( + Guid id, + string scopeId, + Guid staffMemberId, + string subjectId, + WorkspaceStaffAccessTargetState targetState, + WorkspaceStaffAccessRestorationDisposition restorationDisposition, + long targetStaffVersion, + DateOnly effectiveOn, + string requestedBy, + IReadOnlyCollection profileTargets, DateTimeOffset nowUtc) { string normalizedSubject = subjectId?.Trim() ?? string.Empty; @@ -49,6 +80,10 @@ public static Result Create( normalizedSubject.Length is 0 or > SubjectIdMaxLength || normalizedActor.Length is 0 or > ActorIdMaxLength || targetState == WorkspaceStaffAccessTargetState.Unknown || !Enum.IsDefined(targetState) || + !IsRestorationDispositionCoherent( + targetState, + restorationDisposition, + profileTargets) || targetStaffVersion < 2 || effectiveOn == default || profileTargets is null || profileTargets.Any(target => target is null || @@ -65,6 +100,7 @@ target is null || StaffMemberId = staffMemberId, SubjectId = normalizedSubject, TargetState = targetState, + RestorationDisposition = restorationDisposition, TargetStaffVersion = targetStaffVersion, EffectiveOn = effectiveOn, RequestedBy = normalizedActor, @@ -117,6 +153,12 @@ public Result ObserveStaffCommit(DateTimeOffset nowUtc) if (this.TargetState == WorkspaceStaffAccessTargetState.Active) { + if (this.RestorationDisposition == + WorkspaceStaffAccessRestorationDisposition.Suppressed) + { + return this.Complete(nowUtc); + } + this.State = WorkspaceStaffAccessProcessState.RestorationPending; this.Advance(nowUtc); return Result.Success(); @@ -172,4 +214,23 @@ private void Advance(DateTimeOffset nowUtc) this.Version++; this.LastChangedAtUtc = nowUtc; } + + private static bool IsRestorationDispositionCoherent( + WorkspaceStaffAccessTargetState targetState, + WorkspaceStaffAccessRestorationDisposition restorationDisposition, + IReadOnlyCollection? profileTargets) => + targetState switch + { + WorkspaceStaffAccessTargetState.Active => + restorationDisposition == + WorkspaceStaffAccessRestorationDisposition.RestoreSnapshot || + (restorationDisposition == + WorkspaceStaffAccessRestorationDisposition.Suppressed && + profileTargets is { Count: 0 }), + WorkspaceStaffAccessTargetState.Suspended or + WorkspaceStaffAccessTargetState.Departed => + restorationDisposition == + WorkspaceStaffAccessRestorationDisposition.NotApplicable, + _ => false + }; } diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffAccessRestorationDisposition.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffAccessRestorationDisposition.cs new file mode 100644 index 00000000..ff9938ca --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffAccessRestorationDisposition.cs @@ -0,0 +1,8 @@ +namespace BunkFy.Modules.Workspaces.Domain; + +public enum WorkspaceStaffAccessRestorationDisposition +{ + NotApplicable = 1, + RestoreSnapshot = 2, + Suppressed = 3 +} 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.Domain/WorkspaceStaffHistoricalNoProvisionErrors.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffHistoricalNoProvisionErrors.cs new file mode 100644 index 00000000..a8cc381f --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffHistoricalNoProvisionErrors.cs @@ -0,0 +1,10 @@ +namespace BunkFy.Modules.Workspaces.Domain; + +using Gma.Framework.Results; + +public static class WorkspaceStaffHistoricalNoProvisionErrors +{ + public static readonly Error ReceiptInvalid = new( + "Workspaces.StaffHistoricalNoProvisionReceiptInvalid", + "The historical no-provision receipt is invalid."); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffHistoricalNoProvisionReceipt.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffHistoricalNoProvisionReceipt.cs new file mode 100644 index 00000000..ad119fe9 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffHistoricalNoProvisionReceipt.cs @@ -0,0 +1,336 @@ +namespace BunkFy.Modules.Workspaces.Domain; + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Gma.Framework.Domain.Models; +using Gma.Framework.Naming; +using Gma.Framework.Results; + +public sealed class WorkspaceStaffHistoricalNoProvisionReceipt + : ScopedAggregateRoot +{ + public const int CurrentContractVersion = 1; + public const int Sha256Length = 64; + public const int ReviewerIdMaxLength = 256; + public const string SubjectPseudonymPrefix = "no-provision:"; + + private WorkspaceStaffHistoricalNoProvisionReceipt() { } + + private WorkspaceStaffHistoricalNoProvisionReceipt( + Guid id, + string scopeId) + : base(id, scopeId) + { + } + + public int ContractVersion { get; private set; } + public Guid OperationId { get; private set; } + public Guid ApplicationId { get; private set; } + public WorkspaceStaffOnboardingSource SourceKind { get; private set; } + public Guid SourceId { get; private set; } + public long ExpectedApplicationVersion { get; private set; } + public WorkspaceStaffOnboardingState ExpectedApplicationStatus + { + get; + private set; + } + public long ResultApplicationVersion { get; private set; } + public WorkspaceStaffOnboardingState ResultApplicationStatus + { + get; + private set; + } + public long OrganizationsScopeRevision { get; private set; } + public long OrganizationsSourceVersion { get; private set; } + public WorkspaceStaffHistoricalNoProvisionAuthorityStatus + OrganizationsSourceStatus + { get; private set; } + public string StaffEvidenceSha256 { get; private set; } = string.Empty; + public Guid ExternalEvidenceManifestId { get; private set; } + public string ExternalEvidenceSha256 { get; private set; } = string.Empty; + public string ReviewerId { get; private set; } = string.Empty; + public DateTimeOffset ReviewedAtUtc { get; private set; } + public string CanonicalSha256 { get; private set; } = string.Empty; + + public static Result Create( + Guid receiptId, + string tenantId, + Guid operationId, + Guid applicationId, + WorkspaceStaffOnboardingSource sourceKind, + Guid sourceId, + long expectedApplicationVersion, + WorkspaceStaffOnboardingState expectedApplicationStatus, + long resultApplicationVersion, + WorkspaceStaffOnboardingState resultApplicationStatus, + long organizationsScopeRevision, + long organizationsSourceVersion, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + organizationsSourceStatus, + string staffEvidenceSha256, + Guid externalEvidenceManifestId, + string externalEvidenceSha256, + string reviewerId, + DateTimeOffset reviewedAtUtc) + { + string staffDigest = NormalizeSha256(staffEvidenceSha256); + string externalDigest = NormalizeSha256(externalEvidenceSha256); + string reviewer = reviewerId?.Trim() ?? string.Empty; + if (receiptId == Guid.Empty || + operationId == Guid.Empty || + applicationId == Guid.Empty || + sourceId == Guid.Empty || + externalEvidenceManifestId == Guid.Empty || + !TenantIds.TryNormalize(tenantId, out string? scopeId) || + sourceKind is not (WorkspaceStaffOnboardingSource.Invitation or + WorkspaceStaffOnboardingSource.EnrollmentLink) || + !Enum.IsDefined(expectedApplicationStatus) || + expectedApplicationStatus == WorkspaceStaffOnboardingState.Unknown || + !IsTerminal(resultApplicationStatus) || + !IsTerminalAuthority(sourceKind, organizationsSourceStatus) || + !IsSha256(staffDigest) || + !IsSha256(externalDigest) || + reviewer.Length is 0 or > ReviewerIdMaxLength || + reviewer.Any(char.IsControl) || + reviewedAtUtc == default) + { + return Invalid(); + } + + bool expectedTerminal = IsTerminal(expectedApplicationStatus); + bool transitionValid = expectedTerminal + ? resultApplicationStatus == expectedApplicationStatus && + resultApplicationVersion is >= 1 && + resultApplicationVersion >= expectedApplicationVersion && + resultApplicationVersion <= expectedApplicationVersion + 1 + : resultApplicationStatus == + WorkspaceStaffOnboardingState.Superseded && + resultApplicationVersion == expectedApplicationVersion + 1; + if (expectedApplicationVersion is < 1 or long.MaxValue || + organizationsScopeRevision < 0 || + organizationsSourceVersion < 1 || + !transitionValid) + { + return Invalid(); + } + + WorkspaceStaffHistoricalNoProvisionReceipt receipt = new( + receiptId, + scopeId) + { + ContractVersion = CurrentContractVersion, + OperationId = operationId, + ApplicationId = applicationId, + SourceKind = sourceKind, + SourceId = sourceId, + ExpectedApplicationVersion = expectedApplicationVersion, + ExpectedApplicationStatus = expectedApplicationStatus, + ResultApplicationVersion = resultApplicationVersion, + ResultApplicationStatus = resultApplicationStatus, + OrganizationsScopeRevision = organizationsScopeRevision, + OrganizationsSourceVersion = organizationsSourceVersion, + OrganizationsSourceStatus = organizationsSourceStatus, + StaffEvidenceSha256 = staffDigest, + ExternalEvidenceManifestId = externalEvidenceManifestId, + ExternalEvidenceSha256 = externalDigest, + ReviewerId = reviewer, + ReviewedAtUtc = CanonicalizeTimestamp(reviewedAtUtc) + }; + receipt.CanonicalSha256 = receipt.ComputeCanonicalSha256(); + return Result.Success(receipt); + } + + public bool MatchesReplay( + string tenantId, + Guid applicationId, + long expectedApplicationVersion, + WorkspaceStaffOnboardingState expectedApplicationStatus, + long organizationsScopeRevision, + long organizationsSourceVersion, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + organizationsSourceStatus, + Guid externalEvidenceManifestId, + string externalEvidenceSha256, + string reviewerId) => + this.HasValidCanonicalProof() && + string.Equals(this.ScopeId, tenantId, StringComparison.Ordinal) && + this.ApplicationId == applicationId && + this.ExpectedApplicationVersion == expectedApplicationVersion && + this.ExpectedApplicationStatus == expectedApplicationStatus && + this.OrganizationsScopeRevision == organizationsScopeRevision && + this.OrganizationsSourceVersion == organizationsSourceVersion && + this.OrganizationsSourceStatus == organizationsSourceStatus && + this.ExternalEvidenceManifestId == externalEvidenceManifestId && + string.Equals( + this.ExternalEvidenceSha256, + NormalizeSha256(externalEvidenceSha256), + StringComparison.Ordinal) && + string.Equals( + this.ReviewerId, + reviewerId?.Trim(), + StringComparison.Ordinal); + + public bool MatchesResult(WorkspaceStaffOnboarding application) => + application is not null && + this.HasValidCanonicalProof() && + IsTerminal(this.ResultApplicationStatus) && + string.Equals( + this.ScopeId, + application.ScopeId, + StringComparison.Ordinal) && + this.ApplicationId == application.Id && + this.SourceKind == application.SourceKind && + this.SourceId == application.SourceId && + this.ResultApplicationVersion == application.Version && + this.ResultApplicationStatus == application.Status && + string.Equals( + application.SubjectId, + this.CreateSubjectPseudonym(), + StringComparison.Ordinal) && + application.VerifiedAccountEmail is null && + application.DisplayName is null && + application.LegalName is null && + application.WorkEmail is null && + application.WorkPhone is null && + application.EmployeeNumber is null && + application.JobTitle is null && + application.Department is null && + application.FailureCode is null && + !application.HasIdentityAnchorState; + + public bool HasValidCanonicalProof() => + this.ContractVersion == CurrentContractVersion && + IsSha256(this.StaffEvidenceSha256) && + IsSha256(this.ExternalEvidenceSha256) && + IsSha256(this.CanonicalSha256) && + string.Equals( + this.CanonicalSha256, + this.ComputeCanonicalSha256(), + StringComparison.Ordinal); + + public string CreateSubjectPseudonym() => + CreateSubjectPseudonym(this.Id); + + public static string CreateSubjectPseudonym(Guid receiptId) => + receiptId == Guid.Empty + ? string.Empty + : string.Create( + CultureInfo.InvariantCulture, + $"{SubjectPseudonymPrefix}{receiptId:D}"); + + public static bool IsTerminalAuthority( + WorkspaceStaffOnboardingSource sourceKind, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus status) => + sourceKind switch + { + WorkspaceStaffOnboardingSource.Invitation => status is + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationRevoked or + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationSuperseded or + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationExpired, + WorkspaceStaffOnboardingSource.EnrollmentLink => status is + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .EnrollmentLinkDisabled or + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .EnrollmentLinkRotated or + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .EnrollmentLinkExpired, + _ => false + }; + + private string ComputeCanonicalSha256() + { + StringBuilder canonical = new(); + Append(canonical, "workspaces-staff-historical-no-provision|v1"); + Append(canonical, this.ContractVersion); + Append(canonical, this.Id); + Append(canonical, this.ScopeId); + Append(canonical, this.OperationId); + Append(canonical, this.ApplicationId); + Append(canonical, (int)this.SourceKind); + Append(canonical, this.SourceId); + Append(canonical, this.ExpectedApplicationVersion); + Append(canonical, (int)this.ExpectedApplicationStatus); + Append(canonical, this.ResultApplicationVersion); + Append(canonical, (int)this.ResultApplicationStatus); + Append(canonical, this.OrganizationsScopeRevision); + Append(canonical, this.OrganizationsSourceVersion); + Append(canonical, (int)this.OrganizationsSourceStatus); + Append(canonical, this.StaffEvidenceSha256); + Append(canonical, this.ExternalEvidenceManifestId); + Append(canonical, this.ExternalEvidenceSha256); + Append(canonical, this.ReviewerId); + Append(canonical, this.ReviewedAtUtc); + return Convert.ToHexString( + SHA256.HashData( + Encoding.UTF8.GetBytes(canonical.ToString()))) + .ToLowerInvariant(); + } + + private static bool IsTerminal(WorkspaceStaffOnboardingState status) => + status is WorkspaceStaffOnboardingState.Completed or + WorkspaceStaffOnboardingState.Rejected or + WorkspaceStaffOnboardingState.Superseded or + WorkspaceStaffOnboardingState.Expired or + WorkspaceStaffOnboardingState.Withdrawn; + + private static void Append(StringBuilder target, object value) + { + string text = value switch + { + DateTimeOffset timestamp => timestamp.ToUniversalTime().ToString( + "O", + CultureInfo.InvariantCulture), + Guid id => id.ToString("N"), + IFormattable formattable => formattable.ToString( + null, + CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty + }; + target.Append(text.Length.ToString(CultureInfo.InvariantCulture)); + target.Append(':'); + target.Append(text); + } + + private static string NormalizeSha256(string? value) => + value?.Trim().ToLowerInvariant() ?? string.Empty; + + private static DateTimeOffset CanonicalizeTimestamp( + DateTimeOffset value) + { + DateTimeOffset utc = value.ToUniversalTime(); + const long ticksPerMicrosecond = + TimeSpan.TicksPerMillisecond / 1000; + return new( + utc.Ticks - (utc.Ticks % ticksPerMicrosecond), + TimeSpan.Zero); + } + + private static bool IsSha256(string value) => + value.Length == Sha256Length && + value.All(character => character is (>= '0' and <= '9') or + (>= 'a' and <= 'f')); + + private static Result + Invalid() => + Result.Failure( + WorkspaceStaffHistoricalNoProvisionErrors.ReceiptInvalid); +} + +public enum WorkspaceStaffHistoricalNoProvisionAuthorityStatus +{ + Unknown = 0, + InvitationPending = 1, + InvitationAccepted = 2, + InvitationRevoked = 3, + InvitationSuperseded = 4, + InvitationExpired = 5, + EnrollmentLinkActive = 6, + EnrollmentLinkDisabled = 7, + EnrollmentLinkRotated = 8, + EnrollmentLinkExpired = 9, + EnrollmentLinkCapacityReached = 10 +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffIdentityAnchorSweepCheckpoint.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffIdentityAnchorSweepCheckpoint.cs new file mode 100644 index 00000000..133fd5b7 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffIdentityAnchorSweepCheckpoint.cs @@ -0,0 +1,375 @@ +namespace BunkFy.Modules.Workspaces.Domain; + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Gma.Framework.Domain.Models; +using Gma.Framework.Naming; +using Gma.Framework.Results; + +public sealed class WorkspaceStaffIdentityAnchorSweepCheckpoint + : ScopedAggregateRoot +{ + public const int CurrentProtocolVersion = 1; + public const int AdvanceSha256Length = 64; + + private WorkspaceStaffIdentityAnchorSweepCheckpoint() { } + + private WorkspaceStaffIdentityAnchorSweepCheckpoint( + Guid id, + string scopeId) + : base(id, scopeId) + { + } + + public int ProtocolVersion { get; private set; } + public Guid? CycleId { get; private set; } + public long? CycleUpperOrdinal { get; private set; } + public long? AfterOrdinal { get; private set; } + public DateTimeOffset? CycleStartedAtUtc { get; private set; } + public long CycleScannedCount { get; private set; } + public long CycleNoAnchorCount { get; private set; } + public long CycleRemovedCount { get; private set; } + public long CycleObservedCount { get; private set; } + public long CycleAlreadyObservedCount { get; private set; } + public long CycleDeferredCount { get; private set; } + public long CycleConflictCount { get; private set; } + public long CyclePassOneCommittedCount { get; private set; } + public long CycleResolutionRecordConfirmedCount { get; private set; } + public Guid? LastCompletedCycleId { get; private set; } + public long? LastCompletedUpperOrdinal { get; private set; } + public DateTimeOffset? LastCompletedAtUtc { get; private set; } + public long LastCompletedScannedCount { get; private set; } + public long LastCompletedNoAnchorCount { get; private set; } + public long LastCompletedRemovedCount { get; private set; } + public long LastCompletedObservedCount { get; private set; } + public long LastCompletedAlreadyObservedCount { get; private set; } + public long LastCompletedDeferredCount { get; private set; } + public long LastCompletedConflictCount { get; private set; } + public long LastCompletedPassOneCommittedCount { get; private set; } + public long LastCompletedResolutionRecordConfirmedCount { get; private set; } + public Guid? LastAdvanceId { get; private set; } + public string? LastAdvanceSha256 { get; private set; } + public Guid? LastRunId { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } + public long Version { get; private set; } = 1; + + public bool HasActiveCycle => this.CycleId.HasValue; + + public static Result Create( + Guid id, + string tenantId, + DateTimeOffset nowUtc) + { + if (id == Guid.Empty || + !TenantIds.TryNormalize(tenantId, out string? scopeId) || + nowUtc == default) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorSweepErrors.Invalid); + } + + return Result.Success( + new WorkspaceStaffIdentityAnchorSweepCheckpoint(id, scopeId) + { + ProtocolVersion = CurrentProtocolVersion, + UpdatedAtUtc = nowUtc.ToUniversalTime() + }); + } + + public Result BeginCycle( + Guid cycleId, + long upperOrdinal, + Guid runId, + DateTimeOffset nowUtc) + { + DateTimeOffset normalizedNow = nowUtc.ToUniversalTime(); + if (cycleId == Guid.Empty || + upperOrdinal <= 0 || + runId == Guid.Empty || + this.HasActiveCycle || + normalizedNow < this.UpdatedAtUtc) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorSweepErrors.CheckpointConflict); + } + + this.CycleId = cycleId; + this.CycleUpperOrdinal = upperOrdinal; + this.AfterOrdinal = null; + this.CycleStartedAtUtc = normalizedNow; + this.ResetCycleCounts(); + this.LastRunId = runId; + this.UpdatedAtUtc = normalizedNow; + this.Version++; + return Result.Success(); + } + + public Result CompleteEmptyCycle( + Guid cycleId, + Guid advanceId, + Guid runId, + DateTimeOffset nowUtc) + { + string digest = CreateEmptyAdvanceDigest(cycleId, runId); + if (this.LastAdvanceId == advanceId) + { + return !this.HasActiveCycle && + this.LastAdvanceSha256 == digest + ? Result.Success() + : Result.Failure( + WorkspaceStaffIdentityAnchorSweepErrors + .CheckpointConflict); + } + + DateTimeOffset normalizedNow = nowUtc.ToUniversalTime(); + if (cycleId == Guid.Empty || + advanceId == Guid.Empty || + runId == Guid.Empty || + this.HasActiveCycle || + normalizedNow < this.UpdatedAtUtc) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorSweepErrors.CheckpointConflict); + } + + this.LastCompletedCycleId = cycleId; + this.LastCompletedUpperOrdinal = null; + this.LastCompletedAtUtc = normalizedNow; + this.CopyCompletedCounts( + WorkspaceStaffIdentityAnchorSweepPageCounts.Empty); + this.LastAdvanceId = advanceId; + this.LastAdvanceSha256 = digest; + this.LastRunId = runId; + this.UpdatedAtUtc = normalizedNow; + this.Version++; + return Result.Success(); + } + + public Result Advance( + long expectedVersion, + Guid expectedCycleId, + long? expectedAfterOrdinal, + long nextAfterOrdinal, + bool reachedEnd, + Guid advanceId, + Guid runId, + WorkspaceStaffIdentityAnchorSweepPageCounts counts, + DateTimeOffset nowUtc) + { + ArgumentNullException.ThrowIfNull(counts); + string digest = CreateAdvanceDigest( + expectedVersion, + expectedCycleId, + expectedAfterOrdinal, + nextAfterOrdinal, + reachedEnd, + runId, + counts); + if (this.LastAdvanceId == advanceId) + { + return expectedVersion == this.Version - 1 && + this.LastAdvanceSha256 == digest + ? Result.Success() + : Result.Failure( + WorkspaceStaffIdentityAnchorSweepErrors + .CheckpointConflict); + } + + DateTimeOffset normalizedNow = nowUtc.ToUniversalTime(); + if (expectedVersion != this.Version || + expectedCycleId == Guid.Empty || + advanceId == Guid.Empty || + runId == Guid.Empty || + nextAfterOrdinal <= 0 || + this.CycleId != expectedCycleId || + !this.CycleUpperOrdinal.HasValue || + this.AfterOrdinal != expectedAfterOrdinal || + (expectedAfterOrdinal.HasValue && + expectedAfterOrdinal.Value <= 0) || + nextAfterOrdinal > this.CycleUpperOrdinal.Value || + (!reachedEnd && + nextAfterOrdinal >= this.CycleUpperOrdinal.Value) || + (expectedAfterOrdinal.HasValue && + nextAfterOrdinal <= expectedAfterOrdinal.Value) || + !counts.IsValid() || + (!reachedEnd && counts.ScannedCount == 0) || + normalizedNow < this.UpdatedAtUtc) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorSweepErrors.CheckpointConflict); + } + + if (!this.TryAccumulate(counts)) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorSweepErrors.CheckpointConflict); + } + + this.AfterOrdinal = nextAfterOrdinal; + this.LastAdvanceId = advanceId; + this.LastAdvanceSha256 = digest; + this.LastRunId = runId; + this.UpdatedAtUtc = normalizedNow; + if (reachedEnd) + { + this.CompleteActiveCycle(normalizedNow); + } + + this.Version++; + return Result.Success(); + } + + public WorkspaceStaffIdentityAnchorSweepPageCounts CurrentCounts() => + new( + this.CycleScannedCount, + this.CycleNoAnchorCount, + this.CycleRemovedCount, + this.CycleObservedCount, + this.CycleAlreadyObservedCount, + this.CycleDeferredCount, + this.CycleConflictCount, + this.CyclePassOneCommittedCount, + this.CycleResolutionRecordConfirmedCount); + + public WorkspaceStaffIdentityAnchorSweepPageCounts LastCompletedCounts() => + new( + this.LastCompletedScannedCount, + this.LastCompletedNoAnchorCount, + this.LastCompletedRemovedCount, + this.LastCompletedObservedCount, + this.LastCompletedAlreadyObservedCount, + this.LastCompletedDeferredCount, + this.LastCompletedConflictCount, + this.LastCompletedPassOneCommittedCount, + this.LastCompletedResolutionRecordConfirmedCount); + + private static string CreateEmptyAdvanceDigest( + Guid cycleId, + Guid runId) => + Sha256($"empty|{cycleId:D}|{runId:D}"); + + private static string CreateAdvanceDigest( + long expectedVersion, + Guid cycleId, + long? expectedAfterOrdinal, + long nextAfterOrdinal, + bool reachedEnd, + Guid runId, + WorkspaceStaffIdentityAnchorSweepPageCounts counts) + { + string after = expectedAfterOrdinal?.ToString( + CultureInfo.InvariantCulture) ?? "null"; + string canonical = string.Join( + '|', + expectedVersion.ToString(CultureInfo.InvariantCulture), + cycleId.ToString("D"), + after, + nextAfterOrdinal.ToString(CultureInfo.InvariantCulture), + reachedEnd ? "1" : "0", + runId.ToString("D"), + counts.ScannedCount.ToString(CultureInfo.InvariantCulture), + counts.NoAnchorCount.ToString(CultureInfo.InvariantCulture), + counts.RemovedCount.ToString(CultureInfo.InvariantCulture), + counts.ObservedCount.ToString(CultureInfo.InvariantCulture), + counts.AlreadyObservedCount.ToString(CultureInfo.InvariantCulture), + counts.DeferredCount.ToString(CultureInfo.InvariantCulture), + counts.ConflictCount.ToString(CultureInfo.InvariantCulture), + counts.PassOneCommittedCount.ToString(CultureInfo.InvariantCulture), + counts.ResolutionRecordConfirmedCount.ToString( + CultureInfo.InvariantCulture)); + return Sha256(canonical); + } + + private static string Sha256(string value) => + Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(value))) + .ToLowerInvariant(); + + private bool TryAccumulate( + WorkspaceStaffIdentityAnchorSweepPageCounts counts) + { + try + { + long scanned = checked( + this.CycleScannedCount + counts.ScannedCount); + long noAnchor = checked( + this.CycleNoAnchorCount + counts.NoAnchorCount); + long removed = checked( + this.CycleRemovedCount + counts.RemovedCount); + long observed = checked( + this.CycleObservedCount + counts.ObservedCount); + long alreadyObserved = checked( + this.CycleAlreadyObservedCount + + counts.AlreadyObservedCount); + long deferred = checked( + this.CycleDeferredCount + counts.DeferredCount); + long conflict = checked( + this.CycleConflictCount + counts.ConflictCount); + long passOneCommitted = checked( + this.CyclePassOneCommittedCount + + counts.PassOneCommittedCount); + long resolutionRecordConfirmed = checked( + this.CycleResolutionRecordConfirmedCount + + counts.ResolutionRecordConfirmedCount); + this.CycleScannedCount = scanned; + this.CycleNoAnchorCount = noAnchor; + this.CycleRemovedCount = removed; + this.CycleObservedCount = observed; + this.CycleAlreadyObservedCount = alreadyObserved; + this.CycleDeferredCount = deferred; + this.CycleConflictCount = conflict; + this.CyclePassOneCommittedCount = passOneCommitted; + this.CycleResolutionRecordConfirmedCount = + resolutionRecordConfirmed; + return true; + } + catch (OverflowException) + { + return false; + } + } + + private void CompleteActiveCycle(DateTimeOffset nowUtc) + { + this.LastCompletedCycleId = this.CycleId; + this.LastCompletedUpperOrdinal = this.CycleUpperOrdinal; + this.LastCompletedAtUtc = nowUtc; + this.CopyCompletedCounts(this.CurrentCounts()); + this.CycleId = null; + this.CycleUpperOrdinal = null; + this.AfterOrdinal = null; + this.CycleStartedAtUtc = null; + this.ResetCycleCounts(); + } + + private void CopyCompletedCounts( + WorkspaceStaffIdentityAnchorSweepPageCounts counts) + { + this.LastCompletedScannedCount = counts.ScannedCount; + this.LastCompletedNoAnchorCount = counts.NoAnchorCount; + this.LastCompletedRemovedCount = counts.RemovedCount; + this.LastCompletedObservedCount = counts.ObservedCount; + this.LastCompletedAlreadyObservedCount = + counts.AlreadyObservedCount; + this.LastCompletedDeferredCount = counts.DeferredCount; + this.LastCompletedConflictCount = counts.ConflictCount; + this.LastCompletedPassOneCommittedCount = + counts.PassOneCommittedCount; + this.LastCompletedResolutionRecordConfirmedCount = + counts.ResolutionRecordConfirmedCount; + } + + private void ResetCycleCounts() + { + this.CycleScannedCount = 0; + this.CycleNoAnchorCount = 0; + this.CycleRemovedCount = 0; + this.CycleObservedCount = 0; + this.CycleAlreadyObservedCount = 0; + this.CycleDeferredCount = 0; + this.CycleConflictCount = 0; + this.CyclePassOneCommittedCount = 0; + this.CycleResolutionRecordConfirmedCount = 0; + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffIdentityAnchorSweepErrors.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffIdentityAnchorSweepErrors.cs new file mode 100644 index 00000000..d0d9021b --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffIdentityAnchorSweepErrors.cs @@ -0,0 +1,14 @@ +namespace BunkFy.Modules.Workspaces.Domain; + +using Gma.Framework.Results; + +public static class WorkspaceStaffIdentityAnchorSweepErrors +{ + public static readonly Error Invalid = new( + "Workspaces.IdentityAnchorSweepInvalid", + "The identity-anchor sweep checkpoint is invalid."); + + public static readonly Error CheckpointConflict = new( + "Workspaces.IdentityAnchorSweepCheckpointConflict", + "The identity-anchor sweep checkpoint changed concurrently or the replay does not match."); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffIdentityAnchorSweepPageCounts.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffIdentityAnchorSweepPageCounts.cs new file mode 100644 index 00000000..e503e75b --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffIdentityAnchorSweepPageCounts.cs @@ -0,0 +1,55 @@ +namespace BunkFy.Modules.Workspaces.Domain; + +public sealed record WorkspaceStaffIdentityAnchorSweepPageCounts( + long ScannedCount, + long NoAnchorCount, + long RemovedCount, + long ObservedCount, + long AlreadyObservedCount, + long DeferredCount, + long ConflictCount, + long PassOneCommittedCount, + long ResolutionRecordConfirmedCount) +{ + public static readonly WorkspaceStaffIdentityAnchorSweepPageCounts Empty = + new(0, 0, 0, 0, 0, 0, 0, 0, 0); + + public long SettledCount => + this.ObservedCount + this.AlreadyObservedCount; + + public long BacklogCount => this.DeferredCount + this.ConflictCount; + + internal bool IsValid() + { + if (this.ScannedCount < 0 || + this.NoAnchorCount < 0 || + this.RemovedCount < 0 || + this.ObservedCount < 0 || + this.AlreadyObservedCount < 0 || + this.DeferredCount < 0 || + this.ConflictCount < 0 || + this.PassOneCommittedCount < 0 || + this.ResolutionRecordConfirmedCount < 0) + { + return false; + } + + try + { + long classified = checked( + this.NoAnchorCount + + this.RemovedCount + + this.ObservedCount + + this.AlreadyObservedCount + + this.DeferredCount + + this.ConflictCount); + return classified == this.ScannedCount && + this.PassOneCommittedCount <= this.ScannedCount && + this.ResolutionRecordConfirmedCount <= this.ScannedCount; + } + catch (OverflowException) + { + return false; + } + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffOnboarding.IdentityAnchors.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffOnboarding.IdentityAnchors.cs new file mode 100644 index 00000000..f7746efc --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffOnboarding.IdentityAnchors.cs @@ -0,0 +1,377 @@ +namespace BunkFy.Modules.Workspaces.Domain; + +using BunkFy.Modules.Workspaces.Domain.Events; +using Gma.Framework.Results; + +public sealed partial class WorkspaceStaffOnboarding +{ + public Result ConvergeCommittedStaffAnchor( + Guid staffMemberId, + Guid expectedResolutionEventId, + Guid continuationEventId, + DateTimeOffset nowUtc) + { + if (!AreAnchorCoordinatesValid( + this.Id, + staffMemberId, + expectedResolutionEventId, + continuationEventId) || + (this.StaffMemberId.HasValue && + this.StaffMemberId.Value != staffMemberId) || + (this.IdentityAnchorExpectedResolutionEventId.HasValue && + this.IdentityAnchorExpectedResolutionEventId.Value != + expectedResolutionEventId) || + (this.IdentityAnchorContinuationEventId.HasValue && + this.IdentityAnchorContinuationEventId.Value != + continuationEventId) || + this.IdentityAnchorResolutionEventId.HasValue || + this.IsTerminal()) + { + return Result.Failure(WorkspaceStaffOnboardingErrors.StateConflict); + } + + bool alreadyConverged = + this.StaffMemberId == staffMemberId && + this.IdentityAnchorExpectedResolutionEventId == + expectedResolutionEventId && + this.IdentityAnchorContinuationEventId == continuationEventId && + this.Status == WorkspaceStaffOnboardingState.StaffReady && + this.IsApplicantDataRedacted(); + if (alreadyConverged) + { + return Result.Success(); + } + + this.StaffMemberId = staffMemberId; + this.IdentityAnchorExpectedResolutionEventId = + expectedResolutionEventId; + this.IdentityAnchorContinuationEventId = continuationEventId; + this.Status = WorkspaceStaffOnboardingState.StaffReady; + this.FailureCode = null; + this.RedactApplicantData(); + this.Advance(nowUtc); + this.RaiseContinuationRequested(staffMemberId, nowUtc); + return Result.Success(); + } + + public Result ConvergeTerminalStaffAnchor( + Guid staffMemberId, + Guid expectedResolutionEventId, + DateTimeOffset nowUtc) + { + if (staffMemberId == Guid.Empty || + expectedResolutionEventId == Guid.Empty || + expectedResolutionEventId == this.Id || + (this.StaffMemberId.HasValue && + this.StaffMemberId.Value != staffMemberId) || + (this.IdentityAnchorExpectedResolutionEventId.HasValue && + this.IdentityAnchorExpectedResolutionEventId.Value != + expectedResolutionEventId) || + this.IdentityAnchorContinuationEventId == + expectedResolutionEventId) + { + return Result.Failure(WorkspaceStaffOnboardingErrors.StateConflict); + } + + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition disposition = + this.Status switch + { + WorkspaceStaffOnboardingState.Rejected => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted, + WorkspaceStaffOnboardingState.Expired => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted, + WorkspaceStaffOnboardingState.Withdrawn => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted, + WorkspaceStaffOnboardingState.Completed => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + _ => WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted + }; + + if (!this.IsTerminal()) + { + this.Status = WorkspaceStaffOnboardingState.Superseded; + } + + this.StaffMemberId = staffMemberId; + this.IdentityAnchorExpectedResolutionEventId = + expectedResolutionEventId; + this.FailureCode = null; + this.RedactApplicantData(); + return this.RecordResolutionIntent( + staffMemberId, + disposition, + nowUtc); + } + + public Result ConvergeMismatchedStaffAnchor( + Guid staffMemberId, + Guid expectedResolutionEventId, + DateTimeOffset nowUtc) + { + if (staffMemberId == Guid.Empty || + expectedResolutionEventId == Guid.Empty || + expectedResolutionEventId == this.Id || + (this.StaffMemberId.HasValue && + this.StaffMemberId.Value != staffMemberId) || + (this.IdentityAnchorExpectedResolutionEventId.HasValue && + this.IdentityAnchorExpectedResolutionEventId.Value != + expectedResolutionEventId) || + this.IdentityAnchorContinuationEventId == + expectedResolutionEventId) + { + return Result.Failure( + WorkspaceStaffOnboardingErrors.StateConflict); + } + + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition disposition = + this.Status switch + { + WorkspaceStaffOnboardingState.Rejected => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted, + WorkspaceStaffOnboardingState.Expired => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted, + WorkspaceStaffOnboardingState.Withdrawn => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted, + WorkspaceStaffOnboardingState.Superseded => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted, + _ => WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted + }; + if (this.Status is not ( + WorkspaceStaffOnboardingState.Rejected or + WorkspaceStaffOnboardingState.Expired or + WorkspaceStaffOnboardingState.Withdrawn or + WorkspaceStaffOnboardingState.Superseded)) + { + this.Status = WorkspaceStaffOnboardingState.Superseded; + } + + this.StaffMemberId = staffMemberId; + this.IdentityAnchorExpectedResolutionEventId = + expectedResolutionEventId; + this.FailureCode = null; + this.RedactApplicantData(); + return this.RecordResolutionIntent( + staffMemberId, + disposition, + nowUtc); + } + + public Result Complete(DateTimeOffset nowUtc) + { + if (this.Status == WorkspaceStaffOnboardingState.Completed) + { + return Result.Success(); + } + + if (this.Status != WorkspaceStaffOnboardingState.StaffReady || !this.StaffMemberId.HasValue) + { + return Result.Failure(WorkspaceStaffOnboardingErrors.StateConflict); + } + + this.Status = WorkspaceStaffOnboardingState.Completed; + this.FailureCode = null; + this.RedactApplicantData(); + return this.RecordResolutionIntent( + this.StaffMemberId.Value, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + nowUtc); + } + + public Result RecordResolutionIntent( + Guid staffMemberId, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + disposition, + DateTimeOffset intentAtUtc) + { + if (staffMemberId == Guid.Empty || + !IsDefinedResolutionDisposition(disposition) || + this.Id == Guid.Empty || + this.StaffMemberId != staffMemberId || + !this.IdentityAnchorExpectedResolutionEventId.HasValue || + this.IdentityAnchorExpectedResolutionEventId.Value == Guid.Empty || + this.IdentityAnchorExpectedResolutionEventId.Value == this.Id || + this.IdentityAnchorContinuationEventId == + this.IdentityAnchorExpectedResolutionEventId || + !this.IsTerminal() || + !this.IsApplicantDataRedacted()) + { + return Result.Failure(WorkspaceStaffOnboardingErrors.StateConflict); + } + + if (this.IdentityAnchorResolutionEventId.HasValue) + { + return this.IdentityAnchorResolutionEventId == + this.IdentityAnchorExpectedResolutionEventId && + this.IdentityAnchorResolutionStaffMemberId == staffMemberId && + this.IdentityAnchorResolutionApplicationVersion.HasValue && + this.IdentityAnchorResolutionApplicationVersion.Value <= + this.Version && + this.IdentityAnchorResolutionDisposition == disposition + ? Result.Success() + : Result.Failure(WorkspaceStaffOnboardingErrors.StateConflict); + } + + DateTimeOffset normalizedIntentAtUtc = intentAtUtc.ToUniversalTime(); + this.Advance(normalizedIntentAtUtc); + this.IdentityAnchorResolutionEventId = + this.IdentityAnchorExpectedResolutionEventId.Value; + this.IdentityAnchorResolutionStaffMemberId = staffMemberId; + this.IdentityAnchorResolutionApplicationVersion = this.Version; + this.IdentityAnchorResolutionDisposition = disposition; + this.IdentityAnchorResolutionIntentAtUtc = normalizedIntentAtUtc; + this.RaiseDomainEvent( + new WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent( + this.IdentityAnchorResolutionEventId.Value, + normalizedIntentAtUtc, + this.ScopeId, + this.Id, + staffMemberId, + this.Version, + disposition)); + return Result.Success(); + } + + public Result ObserveResolution( + Guid resolutionEventId, + Guid staffMemberId, + long workspaceApplicationVersion, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + disposition, + DateTimeOffset observedAtUtc) + { + if (resolutionEventId == Guid.Empty || + staffMemberId == Guid.Empty || + workspaceApplicationVersion <= 0 || + !IsDefinedResolutionDisposition(disposition) || + this.IdentityAnchorResolutionEventId != resolutionEventId || + this.IdentityAnchorExpectedResolutionEventId != resolutionEventId || + this.IdentityAnchorResolutionStaffMemberId != staffMemberId || + this.IdentityAnchorResolutionApplicationVersion != + workspaceApplicationVersion || + this.IdentityAnchorResolutionDisposition != disposition || + this.StaffMemberId != staffMemberId || + this.Version < workspaceApplicationVersion || + !this.IsTerminal() || + !this.IsApplicantDataRedacted()) + { + return Result.Failure(WorkspaceStaffOnboardingErrors.StateConflict); + } + + if (this.IdentityAnchorResolutionObservedAtUtc.HasValue) + { + return Result.Success(); + } + + DateTimeOffset effectiveObservedAtUtc = observedAtUtc.ToUniversalTime(); + if (this.IdentityAnchorResolutionIntentAtUtc!.Value > + effectiveObservedAtUtc) + { + effectiveObservedAtUtc = + this.IdentityAnchorResolutionIntentAtUtc.Value; + } + + if (this.LastChangedAtUtc > effectiveObservedAtUtc) + { + effectiveObservedAtUtc = this.LastChangedAtUtc; + } + + this.IdentityAnchorResolutionObservedAtUtc = effectiveObservedAtUtc; + this.Advance(effectiveObservedAtUtc); + return Result.Success(); + } + + public Result Fail(string failureCode, DateTimeOffset nowUtc) + { + if (!TryNormalizeRequired( + failureCode, + WorkspaceStaffOnboardingRules.FailureCodeMaxLength, + out string? normalized)) + { + return Result.Failure(WorkspaceStaffOnboardingErrors.Invalid); + } + + if (this.Status is WorkspaceStaffOnboardingState.Completed or + WorkspaceStaffOnboardingState.Rejected or + WorkspaceStaffOnboardingState.Superseded or + WorkspaceStaffOnboardingState.Expired or + WorkspaceStaffOnboardingState.Withdrawn) + { + return Result.Failure(WorkspaceStaffOnboardingErrors.StateConflict); + } + + this.Status = WorkspaceStaffOnboardingState.Failed; + this.FailureCode = normalized; + this.Advance(nowUtc); + return Result.Success(); + } + + public Result Supersede(DateTimeOffset nowUtc) + { + if (this.Status is WorkspaceStaffOnboardingState.Completed or + WorkspaceStaffOnboardingState.Rejected or + WorkspaceStaffOnboardingState.Superseded or + WorkspaceStaffOnboardingState.Expired or + WorkspaceStaffOnboardingState.Withdrawn) + { + return Result.Success(); + } + + this.Status = WorkspaceStaffOnboardingState.Superseded; + this.FailureCode = null; + this.RedactApplicantData(); + this.Advance(nowUtc); + return Result.Success(); + } + + public Result ReviewHistoricalNoProvision( + Guid receiptId, + DateTimeOffset reviewedAtUtc) + { + string pseudonym = WorkspaceStaffHistoricalNoProvisionReceipt + .CreateSubjectPseudonym(receiptId); + if (pseudonym.Length == 0 || + reviewedAtUtc == default || + this.HasIdentityAnchorState || + !Enum.IsDefined(this.Status) || + this.Status == WorkspaceStaffOnboardingState.Unknown) + { + return Result.Failure( + WorkspaceStaffHistoricalNoProvisionErrors.ReceiptInvalid); + } + + bool terminal = this.IsTerminal(); + bool changed = !terminal || + !string.Equals( + this.SubjectId, + pseudonym, + StringComparison.Ordinal) || + !this.IsApplicantDataRedacted() || + this.FailureCode is not null; + if (!changed) + { + return Result.Success(); + } + + if (!terminal) + { + this.Status = WorkspaceStaffOnboardingState.Superseded; + } + + this.SubjectId = pseudonym; + this.FailureCode = null; + this.RedactApplicantData(); + this.Advance(reviewedAtUtc.ToUniversalTime()); + return Result.Success(); + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffOnboarding.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffOnboarding.cs index 5b67aa34..6282ee30 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffOnboarding.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffOnboarding.cs @@ -1,5 +1,6 @@ namespace BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Domain.Events; using Gma.Framework.Domain.Models; using Gma.Framework.Naming; using Gma.Framework.Results; @@ -25,6 +26,15 @@ private WorkspaceStaffOnboarding(Guid id, string scopeId) : base(id, scopeId) { public WorkspaceStaffOnboardingState Status { get; private set; } public Guid? StaffMemberId { get; private set; } public string? FailureCode { get; private set; } + public Guid? IdentityAnchorExpectedResolutionEventId { get; private set; } + public Guid? IdentityAnchorContinuationEventId { get; private set; } + public Guid? IdentityAnchorResolutionEventId { get; private set; } + public Guid? IdentityAnchorResolutionStaffMemberId { get; private set; } + public long? IdentityAnchorResolutionApplicationVersion { get; private set; } + public WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition? IdentityAnchorResolutionDisposition { get; private set; } + public DateTimeOffset? IdentityAnchorResolutionIntentAtUtc { get; private set; } + public DateTimeOffset? IdentityAnchorResolutionObservedAtUtc { get; private set; } + public long IdentityAnchorSweepOrdinal { get; private set; } public long Version { get; private set; } = 1; public DateTimeOffset CreatedAtUtc { get; private set; } public DateTimeOffset LastChangedAtUtc { get; private set; } @@ -50,6 +60,17 @@ WorkspaceStaffOnboardingState.Provisioning or !string.IsNullOrWhiteSpace(this.VerifiedAccountEmail) && !string.IsNullOrWhiteSpace(this.DisplayName); + public bool HasIdentityAnchorState => + this.StaffMemberId.HasValue || + this.IdentityAnchorExpectedResolutionEventId.HasValue || + this.IdentityAnchorContinuationEventId.HasValue || + this.IdentityAnchorResolutionEventId.HasValue || + this.IdentityAnchorResolutionStaffMemberId.HasValue || + this.IdentityAnchorResolutionApplicationVersion.HasValue || + this.IdentityAnchorResolutionDisposition.HasValue || + this.IdentityAnchorResolutionIntentAtUtc.HasValue || + this.IdentityAnchorResolutionObservedAtUtc.HasValue; + public static Result Create( Guid id, string scopeId, @@ -208,80 +229,34 @@ WorkspaceStaffOnboardingState.Expired or : Result.Failure(WorkspaceStaffOnboardingErrors.StateConflict); } - public Result MarkStaffReady(Guid staffMemberId, DateTimeOffset nowUtc) + public Result MarkStaffReady( + Guid staffMemberId, + Guid expectedResolutionEventId, + Guid continuationEventId, + DateTimeOffset nowUtc) { - if (staffMemberId == Guid.Empty || this.Status != WorkspaceStaffOnboardingState.Provisioning) + if (!AreAnchorCoordinatesValid( + this.Id, + staffMemberId, + expectedResolutionEventId, + continuationEventId) || + this.Status != WorkspaceStaffOnboardingState.Provisioning) { return Result.Failure(WorkspaceStaffOnboardingErrors.StateConflict); } this.StaffMemberId = staffMemberId; + this.IdentityAnchorExpectedResolutionEventId = + expectedResolutionEventId; + this.IdentityAnchorContinuationEventId = continuationEventId; this.Status = WorkspaceStaffOnboardingState.StaffReady; - this.Advance(nowUtc); - return Result.Success(); - } - - public Result Complete(DateTimeOffset nowUtc) - { - if (this.Status == WorkspaceStaffOnboardingState.Completed) - { - return Result.Success(); - } - - if (this.Status != WorkspaceStaffOnboardingState.StaffReady || !this.StaffMemberId.HasValue) - { - return Result.Failure(WorkspaceStaffOnboardingErrors.StateConflict); - } - - this.Status = WorkspaceStaffOnboardingState.Completed; this.FailureCode = null; this.RedactApplicantData(); this.Advance(nowUtc); + this.RaiseContinuationRequested(staffMemberId, nowUtc); return Result.Success(); } - public Result Fail(string failureCode, DateTimeOffset nowUtc) - { - if (!TryNormalizeRequired( - failureCode, - WorkspaceStaffOnboardingRules.FailureCodeMaxLength, - out string? normalized)) - { - return Result.Failure(WorkspaceStaffOnboardingErrors.Invalid); - } - - if (this.Status is WorkspaceStaffOnboardingState.Completed or - WorkspaceStaffOnboardingState.Rejected or - WorkspaceStaffOnboardingState.Superseded or - WorkspaceStaffOnboardingState.Expired or - WorkspaceStaffOnboardingState.Withdrawn) - { - return Result.Failure(WorkspaceStaffOnboardingErrors.StateConflict); - } - - this.Status = WorkspaceStaffOnboardingState.Failed; - this.FailureCode = normalized; - this.Advance(nowUtc); - return Result.Success(); - } - - public Result Supersede(DateTimeOffset nowUtc) - { - if (this.Status is WorkspaceStaffOnboardingState.Completed or - WorkspaceStaffOnboardingState.Rejected or - WorkspaceStaffOnboardingState.Superseded or - WorkspaceStaffOnboardingState.Expired or - WorkspaceStaffOnboardingState.Withdrawn) - { - return Result.Success(); - } - - this.Status = WorkspaceStaffOnboardingState.Superseded; - this.FailureCode = null; - this.RedactApplicantData(); - this.Advance(nowUtc); - return Result.Success(); - } public Result Expire(DateTimeOffset nowUtc) { @@ -344,12 +319,77 @@ private void RedactApplicantData() this.Department = null; } + private bool IsApplicantDataRedacted() => + this.VerifiedAccountEmail is null && + this.DisplayName is null && + this.LegalName is null && + this.WorkEmail is null && + this.WorkPhone is null && + this.EmployeeNumber is null && + this.JobTitle is null && + this.Department is null; + + private bool IsTerminal() => this.Status is + WorkspaceStaffOnboardingState.Completed or + WorkspaceStaffOnboardingState.Rejected or + WorkspaceStaffOnboardingState.Superseded or + WorkspaceStaffOnboardingState.Expired or + WorkspaceStaffOnboardingState.Withdrawn; + + private static bool IsDefinedResolutionDisposition( + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + disposition) => + disposition is + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted or + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted or + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted or + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted or + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted; + private void Advance(DateTimeOffset nowUtc) { this.Version++; this.LastChangedAtUtc = nowUtc; } + private void RaiseContinuationRequested( + Guid staffMemberId, + DateTimeOffset occurredAtUtc) + { + if (!this.IdentityAnchorContinuationEventId.HasValue) + { + throw new InvalidOperationException( + "The identity-anchor continuation coordinate is unavailable."); + } + + this.RaiseDomainEvent( + new + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent( + this.IdentityAnchorContinuationEventId.Value, + occurredAtUtc, + this.ScopeId, + this.Id, + staffMemberId)); + } + + private static bool AreAnchorCoordinatesValid( + Guid applicationId, + Guid staffMemberId, + Guid expectedResolutionEventId, + Guid continuationEventId) => + applicationId != Guid.Empty && + staffMemberId != Guid.Empty && + expectedResolutionEventId != Guid.Empty && + continuationEventId != Guid.Empty && + expectedResolutionEventId != applicationId && + continuationEventId != applicationId && + expectedResolutionEventId != continuationEventId; + private static bool TryNormalizeRequired(string? value, int maxLength, out string normalized) { normalized = value?.Trim() ?? string.Empty; diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition.cs new file mode 100644 index 00000000..57aaa095 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition.cs @@ -0,0 +1,11 @@ +namespace BunkFy.Modules.Workspaces.Domain; + +public enum WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition +{ + Unknown = 0, + CompletedRedacted = 1, + RejectedRedacted = 2, + SupersededRedacted = 3, + ExpiredRedacted = 4, + WithdrawnRedacted = 5 +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffRetentionErrors.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffRetentionErrors.cs index 4e22dd54..e41b0cad 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffRetentionErrors.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Domain/WorkspaceStaffRetentionErrors.cs @@ -23,4 +23,8 @@ public static class WorkspaceStaffRetentionErrors public static readonly Error AccessMappingConflict = new( "Workspaces.StaffRetentionCorrelationAccessMappingConflict", "The workspace Staff departure mapping does not match the retention request."); + + public static readonly Error IdentityAnchorUnavailable = new( + "Workspaces.StaffRetentionCorrelationIdentityAnchorUnavailable", + "Workspace Staff identity-anchor resolution must be exact and observed before retention correlation scrubbing."); } 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/20260811205004_AddWorkspaceStaffIdentityProvisioningAnchors.Designer.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations/Migrations/20260811205004_AddWorkspaceStaffIdentityProvisioningAnchors.Designer.cs new file mode 100644 index 00000000..4d339d88 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations/Migrations/20260811205004_AddWorkspaceStaffIdentityProvisioningAnchors.Designer.cs @@ -0,0 +1,1886 @@ +// +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("20260811205004_AddWorkspaceStaffIdentityProvisioningAnchors")] + partial class AddWorkspaceStaffIdentityProvisioningAnchors + { + /// + 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("RestorationDisposition") + .HasColumnType("integer"); + + 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_restoration_disposition", "(\"TargetState\" = 1 AND \"RestorationDisposition\" IN (2, 3)) OR (\"TargetState\" IN (2, 3) AND \"RestorationDisposition\" = 1)"); + + 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", "Id"); + + b.HasIndex("ScopeId", "EnrollmentLinkId", "ClaimVersion"); + + 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.WorkspaceStaffHistoricalNoProvisionReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("CanonicalSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("ExpectedApplicationStatus") + .HasColumnType("integer"); + + b.Property("ExpectedApplicationVersion") + .HasColumnType("bigint"); + + b.Property("ExternalEvidenceManifestId") + .HasColumnType("uuid"); + + b.Property("ExternalEvidenceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("OrganizationsScopeRevision") + .HasColumnType("bigint"); + + b.Property("OrganizationsSourceStatus") + .HasColumnType("integer"); + + b.Property("OrganizationsSourceVersion") + .HasColumnType("bigint"); + + b.Property("ResultApplicationStatus") + .HasColumnType("integer"); + + b.Property("ResultApplicationVersion") + .HasColumnType("bigint"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewerId") + .IsRequired() + .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("StaffEvidenceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "ApplicationId") + .IsUnique(); + + b.HasIndex("ScopeId", "ExternalEvidenceManifestId"); + + b.HasIndex("ScopeId", "OperationId") + .IsUnique(); + + b.HasIndex("ScopeId", "SourceKind", "SourceId"); + + b.ToTable("staff_historical_no_provision_receipts", "workspaces", t => + { + t.HasCheckConstraint("CK_ws_hist_no_prov_authority", "((\"SourceKind\" = 1 AND \"OrganizationsSourceStatus\" IN (3, 4, 5)) OR (\"SourceKind\" = 2 AND \"OrganizationsSourceStatus\" IN (7, 8, 9)))"); + + t.HasCheckConstraint("CK_ws_hist_no_prov_contract", "\"ContractVersion\" = 1"); + + t.HasCheckConstraint("CK_ws_hist_no_prov_hashes", "\"StaffEvidenceSha256\" ~ '^[0-9a-f]{64}$' AND \"ExternalEvidenceSha256\" ~ '^[0-9a-f]{64}$' AND \"CanonicalSha256\" ~ '^[0-9a-f]{64}$'"); + + t.HasCheckConstraint("CK_ws_hist_no_prov_identifiers", "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"OperationId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ApplicationId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"SourceId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ExternalEvidenceManifestId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_ws_hist_no_prov_reviewer", "char_length(\"ReviewerId\") BETWEEN 1 AND 256 AND \"ReviewerId\" = btrim(\"ReviewerId\") AND \"ReviewerId\" !~ '[[:cntrl:]]'"); + + t.HasCheckConstraint("CK_ws_hist_no_prov_transition", "((\"ExpectedApplicationStatus\" IN (5, 7, 8, 9, 10) AND \"ResultApplicationStatus\" = \"ExpectedApplicationStatus\") OR (\"ExpectedApplicationStatus\" IN (1, 2, 3, 4, 6) AND \"ResultApplicationStatus\" = 8 AND \"ResultApplicationVersion\" = \"ExpectedApplicationVersion\" + 1))"); + + t.HasCheckConstraint("CK_ws_hist_no_prov_versions", "\"ExpectedApplicationVersion\" >= 1 AND \"ResultApplicationVersion\" >= \"ExpectedApplicationVersion\" AND \"ResultApplicationVersion\" <= \"ExpectedApplicationVersion\" + 1 AND \"OrganizationsScopeRevision\" >= 0 AND \"OrganizationsSourceVersion\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AfterOrdinal") + .HasColumnType("bigint"); + + b.Property("CycleAlreadyObservedCount") + .HasColumnType("bigint"); + + b.Property("CycleConflictCount") + .HasColumnType("bigint"); + + b.Property("CycleDeferredCount") + .HasColumnType("bigint"); + + b.Property("CycleId") + .HasColumnType("uuid"); + + b.Property("CycleNoAnchorCount") + .HasColumnType("bigint"); + + b.Property("CycleObservedCount") + .HasColumnType("bigint"); + + b.Property("CyclePassOneCommittedCount") + .HasColumnType("bigint"); + + b.Property("CycleRemovedCount") + .HasColumnType("bigint"); + + b.Property("CycleResolutionRecordConfirmedCount") + .HasColumnType("bigint"); + + b.Property("CycleScannedCount") + .HasColumnType("bigint"); + + b.Property("CycleStartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CycleUpperOrdinal") + .HasColumnType("bigint"); + + b.Property("LastAdvanceId") + .HasColumnType("uuid"); + + b.Property("LastAdvanceSha256") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastCompletedAlreadyObservedCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastCompletedConflictCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedCycleId") + .HasColumnType("uuid"); + + b.Property("LastCompletedDeferredCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedNoAnchorCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedObservedCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedPassOneCommittedCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedRemovedCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedResolutionRecordConfirmedCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedScannedCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedUpperOrdinal") + .HasColumnType("bigint"); + + b.Property("LastRunId") + .HasColumnType("uuid"); + + b.Property("ProtocolVersion") + .HasColumnType("integer"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "LastCompletedAtUtc"); + + b.HasIndex("ScopeId", "ProtocolVersion") + .IsUnique(); + + b.ToTable("workspace_staff_identity_anchor_sweep_checkpoints", "workspaces", t => + { + t.HasCheckConstraint("CK_ws_anchor_sweep_cycle", "(\"CycleId\" IS NULL AND \"CycleUpperOrdinal\" IS NULL AND \"AfterOrdinal\" IS NULL AND \"CycleStartedAtUtc\" IS NULL AND \"CycleScannedCount\" = 0) OR (\"CycleId\" IS NOT NULL AND \"CycleUpperOrdinal\" > 0 AND (\"AfterOrdinal\" IS NULL OR (\"AfterOrdinal\" > 0 AND \"AfterOrdinal\" <= \"CycleUpperOrdinal\")) AND \"CycleStartedAtUtc\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_cycle_counts_nonnegative", "\"CycleScannedCount\" >= 0 AND \"CycleNoAnchorCount\" >= 0 AND \"CycleRemovedCount\" >= 0 AND \"CycleObservedCount\" >= 0 AND \"CycleAlreadyObservedCount\" >= 0 AND \"CycleDeferredCount\" >= 0 AND \"CycleConflictCount\" >= 0 AND \"CyclePassOneCommittedCount\" >= 0 AND \"CycleResolutionRecordConfirmedCount\" >= 0"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_cycle_counts_partition", "\"CycleScannedCount\" = \"CycleNoAnchorCount\" + \"CycleRemovedCount\" + \"CycleObservedCount\" + \"CycleAlreadyObservedCount\" + \"CycleDeferredCount\" + \"CycleConflictCount\" AND \"CyclePassOneCommittedCount\" <= \"CycleScannedCount\" AND \"CycleResolutionRecordConfirmedCount\" <= \"CycleScannedCount\""); + + t.HasCheckConstraint("CK_ws_anchor_sweep_identifiers", "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid AND (\"CycleId\" IS NULL OR \"CycleId\" <> '00000000-0000-0000-0000-000000000000'::uuid) AND (\"LastCompletedCycleId\" IS NULL OR \"LastCompletedCycleId\" <> '00000000-0000-0000-0000-000000000000'::uuid) AND (\"LastAdvanceId\" IS NULL OR \"LastAdvanceId\" <> '00000000-0000-0000-0000-000000000000'::uuid) AND (\"LastRunId\" IS NULL OR \"LastRunId\" <> '00000000-0000-0000-0000-000000000000'::uuid)"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_last_advance", "(\"LastAdvanceId\" IS NULL AND \"LastAdvanceSha256\" IS NULL) OR (\"LastAdvanceId\" IS NOT NULL AND \"LastAdvanceSha256\" ~ '^[0-9a-f]{64}$')"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_last_counts_nonnegative", "\"LastCompletedScannedCount\" >= 0 AND \"LastCompletedNoAnchorCount\" >= 0 AND \"LastCompletedRemovedCount\" >= 0 AND \"LastCompletedObservedCount\" >= 0 AND \"LastCompletedAlreadyObservedCount\" >= 0 AND \"LastCompletedDeferredCount\" >= 0 AND \"LastCompletedConflictCount\" >= 0 AND \"LastCompletedPassOneCommittedCount\" >= 0 AND \"LastCompletedResolutionRecordConfirmedCount\" >= 0"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_last_counts_partition", "\"LastCompletedScannedCount\" = \"LastCompletedNoAnchorCount\" + \"LastCompletedRemovedCount\" + \"LastCompletedObservedCount\" + \"LastCompletedAlreadyObservedCount\" + \"LastCompletedDeferredCount\" + \"LastCompletedConflictCount\" AND \"LastCompletedPassOneCommittedCount\" <= \"LastCompletedScannedCount\" AND \"LastCompletedResolutionRecordConfirmedCount\" <= \"LastCompletedScannedCount\""); + + t.HasCheckConstraint("CK_ws_anchor_sweep_last_cycle", "(\"LastCompletedCycleId\" IS NULL AND \"LastCompletedUpperOrdinal\" IS NULL AND \"LastCompletedAtUtc\" IS NULL AND \"LastCompletedScannedCount\" = 0) OR (\"LastCompletedCycleId\" IS NOT NULL AND (\"LastCompletedUpperOrdinal\" > 0 OR (\"LastCompletedUpperOrdinal\" IS NULL AND \"LastCompletedScannedCount\" = 0)) AND \"LastCompletedAtUtc\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_protocol", "\"ProtocolVersion\" = 1"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_run", "(\"CycleId\" IS NULL AND \"LastCompletedCycleId\" IS NULL AND \"LastAdvanceId\" IS NULL AND \"LastRunId\" IS NULL) OR (\"LastRunId\" IS NOT NULL AND (\"CycleId\" IS NOT NULL OR \"LastCompletedCycleId\" IS NOT NULL))"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_times", "(\"CycleStartedAtUtc\" IS NULL OR \"CycleStartedAtUtc\" <= \"UpdatedAtUtc\") AND (\"LastCompletedAtUtc\" IS NULL OR \"LastCompletedAtUtc\" <= \"UpdatedAtUtc\")"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_version", "\"Version\" >= 1"); + }); + }); + + 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("IdentityAnchorContinuationEventId") + .HasColumnType("uuid"); + + b.Property("IdentityAnchorExpectedResolutionEventId") + .HasColumnType("uuid"); + + b.Property("IdentityAnchorResolutionApplicationVersion") + .HasColumnType("bigint"); + + b.Property("IdentityAnchorResolutionDisposition") + .HasColumnType("integer"); + + b.Property("IdentityAnchorResolutionEventId") + .HasColumnType("uuid"); + + b.Property("IdentityAnchorResolutionIntentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityAnchorResolutionObservedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityAnchorResolutionStaffMemberId") + .HasColumnType("uuid"); + + b.Property("IdentityAnchorSweepOrdinal") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("IdentityAnchorSweepOrdinal")); + + 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("IdentityAnchorContinuationEventId") + .IsUnique() + .HasFilter("\"IdentityAnchorContinuationEventId\" IS NOT NULL"); + + b.HasIndex("IdentityAnchorExpectedResolutionEventId") + .IsUnique() + .HasFilter("\"IdentityAnchorExpectedResolutionEventId\" IS NOT NULL"); + + b.HasIndex("ScopeId", "ClaimId") + .IsUnique(); + + b.HasIndex("ScopeId", "IdentityAnchorSweepOrdinal") + .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_anchor_bound_redaction", "\"StaffMemberId\" IS NULL 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_anchor_expected_resolution", "(\"IdentityAnchorExpectedResolutionEventId\" IS NULL OR (\"StaffMemberId\" IS NOT NULL AND \"IdentityAnchorExpectedResolutionEventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"IdentityAnchorExpectedResolutionEventId\" <> \"Id\")) AND (\"IdentityAnchorContinuationEventId\" IS NULL OR (\"IdentityAnchorExpectedResolutionEventId\" IS NOT NULL AND \"IdentityAnchorContinuationEventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"IdentityAnchorContinuationEventId\" <> \"Id\" AND \"IdentityAnchorContinuationEventId\" <> \"IdentityAnchorExpectedResolutionEventId\"))"); + + t.HasCheckConstraint("CK_staff_onboarding_anchor_resolution_coordinates", "\"IdentityAnchorResolutionEventId\" IS NULL OR (\"IdentityAnchorExpectedResolutionEventId\" IS NOT NULL AND \"IdentityAnchorResolutionEventId\" = \"IdentityAnchorExpectedResolutionEventId\" AND \"StaffMemberId\" IS NOT NULL AND \"IdentityAnchorResolutionStaffMemberId\" = \"StaffMemberId\")"); + + t.HasCheckConstraint("CK_staff_onboarding_anchor_resolution_intent", "(\"IdentityAnchorResolutionEventId\" IS NULL AND \"IdentityAnchorResolutionStaffMemberId\" IS NULL AND \"IdentityAnchorResolutionApplicationVersion\" IS NULL AND \"IdentityAnchorResolutionDisposition\" IS NULL AND \"IdentityAnchorResolutionIntentAtUtc\" IS NULL) OR (\"IdentityAnchorResolutionEventId\" IS NOT NULL AND \"StaffMemberId\" IS NOT NULL AND \"IdentityAnchorResolutionStaffMemberId\" IS NOT NULL AND \"IdentityAnchorResolutionApplicationVersion\" > 0 AND \"IdentityAnchorResolutionApplicationVersion\" <= \"Version\" AND \"IdentityAnchorResolutionDisposition\" BETWEEN 1 AND 5 AND \"IdentityAnchorResolutionIntentAtUtc\" IS NOT NULL AND \"IdentityAnchorResolutionIntentAtUtc\" <= \"LastChangedAtUtc\")"); + + t.HasCheckConstraint("CK_staff_onboarding_anchor_resolution_observation", "\"IdentityAnchorResolutionObservedAtUtc\" IS NULL OR (\"IdentityAnchorResolutionEventId\" IS NOT NULL AND \"IdentityAnchorResolutionObservedAtUtc\" >= \"IdentityAnchorResolutionIntentAtUtc\" AND \"IdentityAnchorResolutionObservedAtUtc\" <= \"LastChangedAtUtc\")"); + + t.HasCheckConstraint("CK_staff_onboarding_anchor_resolution_terminal", "\"IdentityAnchorResolutionEventId\" IS NULL OR ((\"Status\" = 5 AND \"IdentityAnchorResolutionDisposition\" = 1) OR (\"Status\" = 7 AND \"IdentityAnchorResolutionDisposition\" = 2) OR (\"Status\" = 8 AND \"IdentityAnchorResolutionDisposition\" = 3) OR (\"Status\" = 9 AND \"IdentityAnchorResolutionDisposition\" = 4) OR (\"Status\" = 10 AND \"IdentityAnchorResolutionDisposition\" = 5))"); + + 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_identity_anchor_sweep_ordinal", "\"IdentityAnchorSweepOrdinal\" > 0"); + + t.HasCheckConstraint("CK_staff_onboarding_pending_profile", "\"StaffMemberId\" IS NOT NULL OR \"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", "(\"StaffMemberId\" IS NULL OR \"StaffMemberId\" <> '00000000-0000-0000-0000-000000000000'::uuid) AND (\"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 (4, 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 22 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/20260811205004_AddWorkspaceStaffIdentityProvisioningAnchors.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations/Migrations/20260811205004_AddWorkspaceStaffIdentityProvisioningAnchors.cs new file mode 100644 index 00000000..542907f3 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations/Migrations/20260811205004_AddWorkspaceStaffIdentityProvisioningAnchors.cs @@ -0,0 +1,2089 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BunkFy.Modules.Workspaces.Persistence.PostgreSqlMigrations.Migrations +{ + /// + public partial class AddWorkspaceStaffIdentityProvisioningAnchors : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + SET LOCAL lock_timeout = '1ms'; + + LOCK TABLE "workspaces"."inbox_messages" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."outbox_messages" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_access_processes" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_access_profile_snapshots" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_correlation_anonymisation_receipts" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_correlation_anonymisation_restore_receipts" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_deferred_claim_withdrawals" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_onboarding_applications" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_onboarding_correction_receipts" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_onboarding_processing_restriction_receipts" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_retention_correlation_receipts" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."tenant_destroy_operations" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."tenant_destroy_receipts" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."workspace_termination_fence_receipts" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."workspace_termination_fences" + IN ACCESS EXCLUSIVE MODE NOWAIT; + + DO $lock_tasks$ + BEGIN + IF to_regclass('tasks.task_runs') IS NOT NULL THEN + EXECUTE + 'LOCK TABLE "tasks"."task_runs" ' || + 'IN SHARE MODE NOWAIT'; + END IF; + END; + $lock_tasks$; + + SET LOCAL lock_timeout = '0'; + + DO $preflight$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM "workspaces"."tenant_destroy_operations" + WHERE "Stage" NOT BETWEEN 1 AND 20) + THEN + RAISE EXCEPTION + 'Workspaces identity-anchor migration found an unknown tenant destruction stage'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "workspaces"."staff_onboarding_applications" + WHERE "StaffMemberId" IS NOT NULL + AND ("VerifiedAccountEmail" IS NOT NULL OR + "DisplayName" IS NOT NULL OR + "LegalName" IS NOT NULL OR + "WorkEmail" IS NOT NULL OR + "WorkPhone" IS NOT NULL OR + "EmployeeNumber" IS NOT NULL OR + "JobTitle" IS NOT NULL OR + "Department" IS NOT NULL) + AND "Version" = 9223372036854775807) + THEN + RAISE EXCEPTION + 'Workspaces identity-anchor migration cannot advance a legacy onboarding version past bigint'; + END IF; + END; + $preflight$; + + """); + + migrationBuilder.DropCheckConstraint( + name: "CK_workspaces_tenant_destroy_operation_progress", + schema: "workspaces", + table: "tenant_destroy_operations"); + + migrationBuilder.Sql( + """ + -- In the deployed predecessor schema 20 meant Completed. + -- It must never transiently acquire the new receipt stage (21). + UPDATE "workspaces"."tenant_destroy_operations" + SET "Stage" = 22 + WHERE "Stage" = 20; + """); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_onboarding_pending_profile", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_onboarding_staff", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_onboarding_terminal_redaction", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.AddColumn( + name: "IdentityAnchorContinuationEventId", + schema: "workspaces", + table: "staff_onboarding_applications", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "IdentityAnchorExpectedResolutionEventId", + schema: "workspaces", + table: "staff_onboarding_applications", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "IdentityAnchorResolutionApplicationVersion", + schema: "workspaces", + table: "staff_onboarding_applications", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "IdentityAnchorResolutionDisposition", + schema: "workspaces", + table: "staff_onboarding_applications", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "IdentityAnchorResolutionEventId", + schema: "workspaces", + table: "staff_onboarding_applications", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "IdentityAnchorResolutionIntentAtUtc", + schema: "workspaces", + table: "staff_onboarding_applications", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IdentityAnchorResolutionObservedAtUtc", + schema: "workspaces", + table: "staff_onboarding_applications", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IdentityAnchorResolutionStaffMemberId", + schema: "workspaces", + table: "staff_onboarding_applications", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "IdentityAnchorSweepOrdinal", + schema: "workspaces", + table: "staff_onboarding_applications", + type: "bigint", + nullable: false, + defaultValue: 0L) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn); + + migrationBuilder.AddColumn( + name: "RestorationDisposition", + schema: "workspaces", + table: "staff_access_processes", + type: "integer", + nullable: true); + + migrationBuilder.Sql( + """ + UPDATE "workspaces"."staff_onboarding_applications" + SET "VerifiedAccountEmail" = NULL, + "DisplayName" = NULL, + "LegalName" = NULL, + "WorkEmail" = NULL, + "WorkPhone" = NULL, + "EmployeeNumber" = NULL, + "JobTitle" = NULL, + "Department" = NULL, + "Version" = "Version" + 1, + "LastChangedAtUtc" = GREATEST( + "LastChangedAtUtc", + statement_timestamp()) + WHERE "StaffMemberId" IS NOT NULL + AND ("VerifiedAccountEmail" IS NOT NULL OR + "DisplayName" IS NOT NULL OR + "LegalName" IS NOT NULL OR + "WorkEmail" IS NOT NULL OR + "WorkPhone" IS NOT NULL OR + "EmployeeNumber" IS NOT NULL OR + "JobTitle" IS NOT NULL OR + "Department" IS NOT NULL); + + UPDATE "workspaces"."staff_access_processes" + SET "RestorationDisposition" = CASE + WHEN "TargetState" = 1 THEN 2 + WHEN "TargetState" IN (2, 3) THEN 1 + ELSE NULL + END; + + DO $backfill$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM "workspaces"."staff_access_processes" + WHERE "RestorationDisposition" IS NULL) + THEN + RAISE EXCEPTION + 'Workspaces access restoration disposition backfill found an unknown target state'; + END IF; + END; + $backfill$; + + ALTER TABLE "workspaces"."staff_access_processes" + ALTER COLUMN "RestorationDisposition" SET NOT NULL; + """); + + migrationBuilder.CreateTable( + name: "staff_historical_no_provision_receipts", + schema: "workspaces", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ContractVersion = table.Column(type: "integer", nullable: false), + OperationId = table.Column(type: "uuid", nullable: false), + ApplicationId = table.Column(type: "uuid", nullable: false), + SourceKind = table.Column(type: "integer", nullable: false), + SourceId = table.Column(type: "uuid", nullable: false), + ExpectedApplicationVersion = table.Column(type: "bigint", nullable: false), + ExpectedApplicationStatus = table.Column(type: "integer", nullable: false), + ResultApplicationVersion = table.Column(type: "bigint", nullable: false), + ResultApplicationStatus = table.Column(type: "integer", nullable: false), + OrganizationsScopeRevision = table.Column(type: "bigint", nullable: false), + OrganizationsSourceVersion = table.Column(type: "bigint", nullable: false), + OrganizationsSourceStatus = table.Column(type: "integer", nullable: false), + StaffEvidenceSha256 = table.Column(type: "character(64)", fixedLength: true, maxLength: 64, nullable: false), + ExternalEvidenceManifestId = table.Column(type: "uuid", nullable: false), + ExternalEvidenceSha256 = table.Column(type: "character(64)", fixedLength: true, maxLength: 64, nullable: false), + ReviewerId = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + ReviewedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + CanonicalSha256 = table.Column(type: "character(64)", fixedLength: true, maxLength: 64, nullable: false), + ScopeId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_staff_historical_no_provision_receipts", x => x.Id); + table.UniqueConstraint("AK_staff_historical_no_provision_receipts_ScopeId_Id", x => new { x.ScopeId, x.Id }); + table.CheckConstraint("CK_ws_hist_no_prov_authority", "((\"SourceKind\" = 1 AND \"OrganizationsSourceStatus\" IN (3, 4, 5)) OR (\"SourceKind\" = 2 AND \"OrganizationsSourceStatus\" IN (7, 8, 9)))"); + table.CheckConstraint("CK_ws_hist_no_prov_contract", "\"ContractVersion\" = 1"); + table.CheckConstraint("CK_ws_hist_no_prov_hashes", "\"StaffEvidenceSha256\" ~ '^[0-9a-f]{64}$' AND \"ExternalEvidenceSha256\" ~ '^[0-9a-f]{64}$' AND \"CanonicalSha256\" ~ '^[0-9a-f]{64}$'"); + table.CheckConstraint("CK_ws_hist_no_prov_identifiers", "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"OperationId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ApplicationId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"SourceId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ExternalEvidenceManifestId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + table.CheckConstraint("CK_ws_hist_no_prov_reviewer", "char_length(\"ReviewerId\") BETWEEN 1 AND 256 AND \"ReviewerId\" = btrim(\"ReviewerId\") AND \"ReviewerId\" !~ '[[:cntrl:]]'"); + table.CheckConstraint("CK_ws_hist_no_prov_transition", "((\"ExpectedApplicationStatus\" IN (5, 7, 8, 9, 10) AND \"ResultApplicationStatus\" = \"ExpectedApplicationStatus\") OR (\"ExpectedApplicationStatus\" IN (1, 2, 3, 4, 6) AND \"ResultApplicationStatus\" = 8 AND \"ResultApplicationVersion\" = \"ExpectedApplicationVersion\" + 1))"); + table.CheckConstraint("CK_ws_hist_no_prov_versions", "\"ExpectedApplicationVersion\" >= 1 AND \"ResultApplicationVersion\" >= \"ExpectedApplicationVersion\" AND \"ResultApplicationVersion\" <= \"ExpectedApplicationVersion\" + 1 AND \"OrganizationsScopeRevision\" >= 0 AND \"OrganizationsSourceVersion\" >= 1"); + }); + + migrationBuilder.CreateTable( + name: "workspace_staff_identity_anchor_sweep_checkpoints", + schema: "workspaces", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProtocolVersion = table.Column(type: "integer", nullable: false), + CycleId = table.Column(type: "uuid", nullable: true), + CycleUpperOrdinal = table.Column(type: "bigint", nullable: true), + AfterOrdinal = table.Column(type: "bigint", nullable: true), + CycleStartedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + CycleScannedCount = table.Column(type: "bigint", nullable: false), + CycleNoAnchorCount = table.Column(type: "bigint", nullable: false), + CycleRemovedCount = table.Column(type: "bigint", nullable: false), + CycleObservedCount = table.Column(type: "bigint", nullable: false), + CycleAlreadyObservedCount = table.Column(type: "bigint", nullable: false), + CycleDeferredCount = table.Column(type: "bigint", nullable: false), + CycleConflictCount = table.Column(type: "bigint", nullable: false), + CyclePassOneCommittedCount = table.Column(type: "bigint", nullable: false), + CycleResolutionRecordConfirmedCount = table.Column(type: "bigint", nullable: false), + LastCompletedCycleId = table.Column(type: "uuid", nullable: true), + LastCompletedUpperOrdinal = table.Column(type: "bigint", nullable: true), + LastCompletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + LastCompletedScannedCount = table.Column(type: "bigint", nullable: false), + LastCompletedNoAnchorCount = table.Column(type: "bigint", nullable: false), + LastCompletedRemovedCount = table.Column(type: "bigint", nullable: false), + LastCompletedObservedCount = table.Column(type: "bigint", nullable: false), + LastCompletedAlreadyObservedCount = table.Column(type: "bigint", nullable: false), + LastCompletedDeferredCount = table.Column(type: "bigint", nullable: false), + LastCompletedConflictCount = table.Column(type: "bigint", nullable: false), + LastCompletedPassOneCommittedCount = table.Column(type: "bigint", nullable: false), + LastCompletedResolutionRecordConfirmedCount = table.Column(type: "bigint", nullable: false), + LastAdvanceId = table.Column(type: "uuid", nullable: true), + LastAdvanceSha256 = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + LastRunId = table.Column(type: "uuid", nullable: true), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + Version = table.Column(type: "bigint", nullable: false), + ScopeId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_workspace_staff_identity_anchor_sweep_checkpoints", x => x.Id); + table.UniqueConstraint("AK_workspace_staff_identity_anchor_sweep_checkpoints_ScopeId_Id", x => new { x.ScopeId, x.Id }); + table.CheckConstraint("CK_ws_anchor_sweep_cycle", "(\"CycleId\" IS NULL AND \"CycleUpperOrdinal\" IS NULL AND \"AfterOrdinal\" IS NULL AND \"CycleStartedAtUtc\" IS NULL AND \"CycleScannedCount\" = 0) OR (\"CycleId\" IS NOT NULL AND \"CycleUpperOrdinal\" > 0 AND (\"AfterOrdinal\" IS NULL OR (\"AfterOrdinal\" > 0 AND \"AfterOrdinal\" <= \"CycleUpperOrdinal\")) AND \"CycleStartedAtUtc\" IS NOT NULL)"); + table.CheckConstraint("CK_ws_anchor_sweep_cycle_counts_nonnegative", "\"CycleScannedCount\" >= 0 AND \"CycleNoAnchorCount\" >= 0 AND \"CycleRemovedCount\" >= 0 AND \"CycleObservedCount\" >= 0 AND \"CycleAlreadyObservedCount\" >= 0 AND \"CycleDeferredCount\" >= 0 AND \"CycleConflictCount\" >= 0 AND \"CyclePassOneCommittedCount\" >= 0 AND \"CycleResolutionRecordConfirmedCount\" >= 0"); + table.CheckConstraint("CK_ws_anchor_sweep_cycle_counts_partition", "\"CycleScannedCount\" = \"CycleNoAnchorCount\" + \"CycleRemovedCount\" + \"CycleObservedCount\" + \"CycleAlreadyObservedCount\" + \"CycleDeferredCount\" + \"CycleConflictCount\" AND \"CyclePassOneCommittedCount\" <= \"CycleScannedCount\" AND \"CycleResolutionRecordConfirmedCount\" <= \"CycleScannedCount\""); + table.CheckConstraint("CK_ws_anchor_sweep_identifiers", "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid AND (\"CycleId\" IS NULL OR \"CycleId\" <> '00000000-0000-0000-0000-000000000000'::uuid) AND (\"LastCompletedCycleId\" IS NULL OR \"LastCompletedCycleId\" <> '00000000-0000-0000-0000-000000000000'::uuid) AND (\"LastAdvanceId\" IS NULL OR \"LastAdvanceId\" <> '00000000-0000-0000-0000-000000000000'::uuid) AND (\"LastRunId\" IS NULL OR \"LastRunId\" <> '00000000-0000-0000-0000-000000000000'::uuid)"); + table.CheckConstraint("CK_ws_anchor_sweep_last_advance", "(\"LastAdvanceId\" IS NULL AND \"LastAdvanceSha256\" IS NULL) OR (\"LastAdvanceId\" IS NOT NULL AND \"LastAdvanceSha256\" ~ '^[0-9a-f]{64}$')"); + table.CheckConstraint("CK_ws_anchor_sweep_last_counts_nonnegative", "\"LastCompletedScannedCount\" >= 0 AND \"LastCompletedNoAnchorCount\" >= 0 AND \"LastCompletedRemovedCount\" >= 0 AND \"LastCompletedObservedCount\" >= 0 AND \"LastCompletedAlreadyObservedCount\" >= 0 AND \"LastCompletedDeferredCount\" >= 0 AND \"LastCompletedConflictCount\" >= 0 AND \"LastCompletedPassOneCommittedCount\" >= 0 AND \"LastCompletedResolutionRecordConfirmedCount\" >= 0"); + table.CheckConstraint("CK_ws_anchor_sweep_last_counts_partition", "\"LastCompletedScannedCount\" = \"LastCompletedNoAnchorCount\" + \"LastCompletedRemovedCount\" + \"LastCompletedObservedCount\" + \"LastCompletedAlreadyObservedCount\" + \"LastCompletedDeferredCount\" + \"LastCompletedConflictCount\" AND \"LastCompletedPassOneCommittedCount\" <= \"LastCompletedScannedCount\" AND \"LastCompletedResolutionRecordConfirmedCount\" <= \"LastCompletedScannedCount\""); + table.CheckConstraint("CK_ws_anchor_sweep_last_cycle", "(\"LastCompletedCycleId\" IS NULL AND \"LastCompletedUpperOrdinal\" IS NULL AND \"LastCompletedAtUtc\" IS NULL AND \"LastCompletedScannedCount\" = 0) OR (\"LastCompletedCycleId\" IS NOT NULL AND (\"LastCompletedUpperOrdinal\" > 0 OR (\"LastCompletedUpperOrdinal\" IS NULL AND \"LastCompletedScannedCount\" = 0)) AND \"LastCompletedAtUtc\" IS NOT NULL)"); + table.CheckConstraint("CK_ws_anchor_sweep_protocol", "\"ProtocolVersion\" = 1"); + table.CheckConstraint("CK_ws_anchor_sweep_run", "(\"CycleId\" IS NULL AND \"LastCompletedCycleId\" IS NULL AND \"LastAdvanceId\" IS NULL AND \"LastRunId\" IS NULL) OR (\"LastRunId\" IS NOT NULL AND (\"CycleId\" IS NOT NULL OR \"LastCompletedCycleId\" IS NOT NULL))"); + table.CheckConstraint("CK_ws_anchor_sweep_times", "(\"CycleStartedAtUtc\" IS NULL OR \"CycleStartedAtUtc\" <= \"UpdatedAtUtc\") AND (\"LastCompletedAtUtc\" IS NULL OR \"LastCompletedAtUtc\" <= \"UpdatedAtUtc\")"); + table.CheckConstraint("CK_ws_anchor_sweep_version", "\"Version\" >= 1"); + }); + + migrationBuilder.AddCheckConstraint( + name: "CK_workspaces_tenant_destroy_operation_progress", + schema: "workspaces", + table: "tenant_destroy_operations", + sql: "\"Stage\" BETWEEN 1 AND 22 AND \"RemovedRecordCount\" >= 0 AND \"CompletedBatchCount\" >= 0 AND \"ProofVersion\" = 1 AND \"ConcurrencyVersion\" >= 1"); + + migrationBuilder.CreateIndex( + name: "IX_staff_onboarding_applications_IdentityAnchorContinuationEve~", + schema: "workspaces", + table: "staff_onboarding_applications", + column: "IdentityAnchorContinuationEventId", + unique: true, + filter: "\"IdentityAnchorContinuationEventId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_staff_onboarding_applications_IdentityAnchorExpectedResolut~", + schema: "workspaces", + table: "staff_onboarding_applications", + column: "IdentityAnchorExpectedResolutionEventId", + unique: true, + filter: "\"IdentityAnchorExpectedResolutionEventId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_staff_onboarding_applications_ScopeId_IdentityAnchorSweepOr~", + schema: "workspaces", + table: "staff_onboarding_applications", + columns: new[] { "ScopeId", "IdentityAnchorSweepOrdinal" }, + unique: true); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_onboarding_anchor_bound_redaction", + schema: "workspaces", + table: "staff_onboarding_applications", + sql: "\"StaffMemberId\" IS NULL 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)"); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_onboarding_anchor_expected_resolution", + schema: "workspaces", + table: "staff_onboarding_applications", + sql: "(\"IdentityAnchorExpectedResolutionEventId\" IS NULL OR (\"StaffMemberId\" IS NOT NULL AND \"IdentityAnchorExpectedResolutionEventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"IdentityAnchorExpectedResolutionEventId\" <> \"Id\")) AND (\"IdentityAnchorContinuationEventId\" IS NULL OR (\"IdentityAnchorExpectedResolutionEventId\" IS NOT NULL AND \"IdentityAnchorContinuationEventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"IdentityAnchorContinuationEventId\" <> \"Id\" AND \"IdentityAnchorContinuationEventId\" <> \"IdentityAnchorExpectedResolutionEventId\"))"); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_onboarding_anchor_resolution_coordinates", + schema: "workspaces", + table: "staff_onboarding_applications", + sql: "\"IdentityAnchorResolutionEventId\" IS NULL OR (\"IdentityAnchorExpectedResolutionEventId\" IS NOT NULL AND \"IdentityAnchorResolutionEventId\" = \"IdentityAnchorExpectedResolutionEventId\" AND \"StaffMemberId\" IS NOT NULL AND \"IdentityAnchorResolutionStaffMemberId\" = \"StaffMemberId\")"); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_onboarding_anchor_resolution_intent", + schema: "workspaces", + table: "staff_onboarding_applications", + sql: "(\"IdentityAnchorResolutionEventId\" IS NULL AND \"IdentityAnchorResolutionStaffMemberId\" IS NULL AND \"IdentityAnchorResolutionApplicationVersion\" IS NULL AND \"IdentityAnchorResolutionDisposition\" IS NULL AND \"IdentityAnchorResolutionIntentAtUtc\" IS NULL) OR (\"IdentityAnchorResolutionEventId\" IS NOT NULL AND \"StaffMemberId\" IS NOT NULL AND \"IdentityAnchorResolutionStaffMemberId\" IS NOT NULL AND \"IdentityAnchorResolutionApplicationVersion\" > 0 AND \"IdentityAnchorResolutionApplicationVersion\" <= \"Version\" AND \"IdentityAnchorResolutionDisposition\" BETWEEN 1 AND 5 AND \"IdentityAnchorResolutionIntentAtUtc\" IS NOT NULL AND \"IdentityAnchorResolutionIntentAtUtc\" <= \"LastChangedAtUtc\")"); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_onboarding_anchor_resolution_observation", + schema: "workspaces", + table: "staff_onboarding_applications", + sql: "\"IdentityAnchorResolutionObservedAtUtc\" IS NULL OR (\"IdentityAnchorResolutionEventId\" IS NOT NULL AND \"IdentityAnchorResolutionObservedAtUtc\" >= \"IdentityAnchorResolutionIntentAtUtc\" AND \"IdentityAnchorResolutionObservedAtUtc\" <= \"LastChangedAtUtc\")"); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_onboarding_anchor_resolution_terminal", + schema: "workspaces", + table: "staff_onboarding_applications", + sql: "\"IdentityAnchorResolutionEventId\" IS NULL OR ((\"Status\" = 5 AND \"IdentityAnchorResolutionDisposition\" = 1) OR (\"Status\" = 7 AND \"IdentityAnchorResolutionDisposition\" = 2) OR (\"Status\" = 8 AND \"IdentityAnchorResolutionDisposition\" = 3) OR (\"Status\" = 9 AND \"IdentityAnchorResolutionDisposition\" = 4) OR (\"Status\" = 10 AND \"IdentityAnchorResolutionDisposition\" = 5))"); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_onboarding_identity_anchor_sweep_ordinal", + schema: "workspaces", + table: "staff_onboarding_applications", + sql: "\"IdentityAnchorSweepOrdinal\" > 0"); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_onboarding_pending_profile", + schema: "workspaces", + table: "staff_onboarding_applications", + sql: "\"StaffMemberId\" IS NOT NULL OR \"Status\" IN (5, 7, 8, 9, 10) OR (\"VerifiedAccountEmail\" IS NOT NULL AND \"DisplayName\" IS NOT NULL)"); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_onboarding_staff", + schema: "workspaces", + table: "staff_onboarding_applications", + sql: "(\"StaffMemberId\" IS NULL OR \"StaffMemberId\" <> '00000000-0000-0000-0000-000000000000'::uuid) AND (\"Status\" NOT IN (4, 5) OR \"StaffMemberId\" IS NOT NULL)"); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_onboarding_terminal_redaction", + schema: "workspaces", + table: "staff_onboarding_applications", + sql: "\"Status\" NOT IN (4, 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)"); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_access_process_restoration_disposition", + schema: "workspaces", + table: "staff_access_processes", + sql: "(\"TargetState\" = 1 AND \"RestorationDisposition\" IN (2, 3)) OR (\"TargetState\" IN (2, 3) AND \"RestorationDisposition\" = 1)"); + + migrationBuilder.CreateIndex( + name: "IX_staff_historical_no_provision_receipts_ScopeId_ApplicationId", + schema: "workspaces", + table: "staff_historical_no_provision_receipts", + columns: new[] { "ScopeId", "ApplicationId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_staff_historical_no_provision_receipts_ScopeId_ExternalEvid~", + schema: "workspaces", + table: "staff_historical_no_provision_receipts", + columns: new[] { "ScopeId", "ExternalEvidenceManifestId" }); + + migrationBuilder.CreateIndex( + name: "IX_staff_historical_no_provision_receipts_ScopeId_OperationId", + schema: "workspaces", + table: "staff_historical_no_provision_receipts", + columns: new[] { "ScopeId", "OperationId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_staff_historical_no_provision_receipts_ScopeId_SourceKind_S~", + schema: "workspaces", + table: "staff_historical_no_provision_receipts", + columns: new[] { "ScopeId", "SourceKind", "SourceId" }); + + migrationBuilder.CreateIndex( + name: "IX_workspace_staff_identity_anchor_sweep_checkpoints_ScopeId_L~", + schema: "workspaces", + table: "workspace_staff_identity_anchor_sweep_checkpoints", + columns: new[] { "ScopeId", "LastCompletedAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_workspace_staff_identity_anchor_sweep_checkpoints_ScopeId_P~", + schema: "workspaces", + table: "workspace_staff_identity_anchor_sweep_checkpoints", + columns: new[] { "ScopeId", "ProtocolVersion" }, + unique: true); + + migrationBuilder.Sql( + """ + -- The identity sequence is global. The INSERT trigger below + -- replaces every candidate ordinal, including OVERRIDING SYSTEM + -- VALUE input, with a fresh nextval. Global uniqueness remains a + -- defense-in-depth invariant; burned identity values are harmless. + CREATE UNIQUE INDEX + "UX_staff_onboarding_applications_IdentityAnchorSweepOrdinal" + ON "workspaces"."staff_onboarding_applications" + ("IdentityAnchorSweepOrdinal"); + """); + + migrationBuilder.Sql( + """ + CREATE OR REPLACE FUNCTION + "workspaces".tenant_destroy_delete_admitted( + requested_scope_id text, + requested_stage integer) + RETURNS boolean + LANGUAGE sql + STABLE + AS $function$ + SELECT + current_setting( + 'bunkfy.workspaces_tenant_destroy_operation_id', + true) IS NOT NULL + AND current_setting( + 'bunkfy.workspaces_tenant_destroy_attempted_stage', + true) = requested_stage::text + AND EXISTS ( + SELECT 1 + FROM "workspaces"."tenant_destroy_operations" operation + INNER JOIN "workspaces"."workspace_termination_fences" fence + ON fence."ScopeId" = operation."ScopeId" + AND fence."Id" = operation."FenceId" + WHERE operation."OperationId"::text = + current_setting( + 'bunkfy.workspaces_tenant_destroy_operation_id', + true) + AND operation."ScopeId" = requested_scope_id + AND operation."Stage" IN ( + requested_stage, + requested_stage + 1) + AND fence."State" = 2); + $function$; + + CREATE OR REPLACE FUNCTION + "workspaces".prevent_receipt_mutation() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + required_stage integer; + BEGIN + required_stage := CASE TG_TABLE_NAME + WHEN 'staff_onboarding_correction_receipts' THEN 5 + WHEN 'staff_onboarding_processing_restriction_receipts' THEN 6 + WHEN 'staff_retention_correlation_receipts' THEN 14 + WHEN 'staff_correlation_anonymisation_restore_receipts' THEN 15 + WHEN 'staff_correlation_anonymisation_receipts' THEN 17 + WHEN 'workspace_termination_fence_receipts' THEN 18 + WHEN 'staff_historical_no_provision_receipts' THEN 21 + ELSE NULL + END; + + IF TG_OP = 'DELETE' + AND required_stage IS NOT NULL + AND "workspaces".tenant_destroy_delete_admitted( + OLD."ScopeId", + required_stage) + THEN + RETURN OLD; + END IF; + + RAISE EXCEPTION 'workspace receipts are append-only'; + END; + $function$; + + CREATE TRIGGER + "TR_staff_onboarding_correction_receipts_append_only" + BEFORE UPDATE OR DELETE + ON "workspaces"."staff_onboarding_correction_receipts" + FOR EACH ROW + EXECUTE FUNCTION "workspaces".prevent_receipt_mutation(); + + CREATE FUNCTION + "workspaces".enforce_staff_onboarding_sweep_ordinal() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + sequence_name text; + BEGIN + IF TG_OP = 'UPDATE' THEN + IF NEW."IdentityAnchorSweepOrdinal" IS DISTINCT FROM + OLD."IdentityAnchorSweepOrdinal" + THEN + RAISE EXCEPTION + 'Workspace Staff onboarding sweep ordinals are immutable'; + END IF; + RETURN NEW; + END IF; + + sequence_name := pg_get_serial_sequence( + 'workspaces.staff_onboarding_applications', + 'IdentityAnchorSweepOrdinal'); + IF sequence_name IS NULL THEN + RAISE EXCEPTION + 'Workspace Staff onboarding sweep ordinal sequence is unavailable'; + END IF; + + NEW."IdentityAnchorSweepOrdinal" := + nextval(sequence_name::regclass); + RETURN NEW; + END; + $function$; + + CREATE TRIGGER + "TR_staff_onboarding_sweep_ordinal" + BEFORE INSERT OR UPDATE OF "IdentityAnchorSweepOrdinal" + ON "workspaces"."staff_onboarding_applications" + FOR EACH ROW + EXECUTE FUNCTION + "workspaces".enforce_staff_onboarding_sweep_ordinal(); + + CREATE FUNCTION + "workspaces".enforce_staff_onboarding_anchor_coordinates() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + anchor_changed boolean; + resolution_was_empty boolean; + resolution_is_any boolean; + resolution_is_complete boolean; + BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW."StaffMemberId" IS NOT NULL + OR NEW."IdentityAnchorExpectedResolutionEventId" IS NOT NULL + OR NEW."IdentityAnchorContinuationEventId" IS NOT NULL + OR NEW."IdentityAnchorResolutionEventId" IS NOT NULL + OR NEW."IdentityAnchorResolutionStaffMemberId" IS NOT NULL + OR NEW."IdentityAnchorResolutionApplicationVersion" IS NOT NULL + OR NEW."IdentityAnchorResolutionDisposition" IS NOT NULL + OR NEW."IdentityAnchorResolutionIntentAtUtc" IS NOT NULL + OR NEW."IdentityAnchorResolutionObservedAtUtc" IS NOT NULL + THEN + RAISE EXCEPTION + 'Workspace Staff onboarding applications must be inserted without Staff or identity-anchor coordinates'; + END IF; + RETURN NEW; + END IF; + + IF OLD."StaffMemberId" IS NOT NULL + AND NEW."StaffMemberId" IS DISTINCT FROM OLD."StaffMemberId" + THEN + RAISE EXCEPTION + 'Workspace Staff onboarding anchor Staff coordinate is immutable'; + END IF; + + IF OLD."StaffMemberId" IS NULL + AND NEW."StaffMemberId" IS NOT NULL + AND NEW."IdentityAnchorExpectedResolutionEventId" IS NULL + THEN + RAISE EXCEPTION + 'Workspace Staff onboarding Staff binding requires an expected resolution coordinate'; + END IF; + + IF OLD."IdentityAnchorExpectedResolutionEventId" IS NOT NULL + AND NEW."IdentityAnchorExpectedResolutionEventId" IS DISTINCT FROM + OLD."IdentityAnchorExpectedResolutionEventId" + THEN + RAISE EXCEPTION + 'Workspace Staff onboarding expected resolution coordinate is immutable'; + END IF; + + IF OLD."IdentityAnchorContinuationEventId" IS NOT NULL + AND NEW."IdentityAnchorContinuationEventId" IS DISTINCT FROM + OLD."IdentityAnchorContinuationEventId" + THEN + RAISE EXCEPTION + 'Workspace Staff onboarding continuation coordinate is immutable'; + END IF; + + IF OLD."IdentityAnchorContinuationEventId" IS NULL + AND NEW."IdentityAnchorContinuationEventId" IS NOT NULL + AND OLD."IdentityAnchorResolutionEventId" IS NOT NULL + THEN + RAISE EXCEPTION + 'Workspace Staff onboarding continuation cannot be appended after terminal resolution'; + END IF; + + IF OLD."IdentityAnchorExpectedResolutionEventId" IS NULL + AND NEW."IdentityAnchorExpectedResolutionEventId" IS NOT NULL + AND ((NEW."IdentityAnchorContinuationEventId" IS NULL) = + (NEW."IdentityAnchorResolutionEventId" IS NULL)) + THEN + RAISE EXCEPTION + 'Workspace Staff onboarding initial anchor binding requires exactly one continuation or terminal resolution'; + END IF; + + resolution_was_empty := + OLD."IdentityAnchorResolutionEventId" IS NULL + AND OLD."IdentityAnchorResolutionStaffMemberId" IS NULL + AND OLD."IdentityAnchorResolutionApplicationVersion" IS NULL + AND OLD."IdentityAnchorResolutionDisposition" IS NULL + AND OLD."IdentityAnchorResolutionIntentAtUtc" IS NULL; + resolution_is_any := + NEW."IdentityAnchorResolutionEventId" IS NOT NULL + OR NEW."IdentityAnchorResolutionStaffMemberId" IS NOT NULL + OR NEW."IdentityAnchorResolutionApplicationVersion" IS NOT NULL + OR NEW."IdentityAnchorResolutionDisposition" IS NOT NULL + OR NEW."IdentityAnchorResolutionIntentAtUtc" IS NOT NULL; + resolution_is_complete := + NEW."IdentityAnchorResolutionEventId" IS NOT NULL + AND NEW."IdentityAnchorResolutionStaffMemberId" IS NOT NULL + AND NEW."IdentityAnchorResolutionApplicationVersion" IS NOT NULL + AND NEW."IdentityAnchorResolutionDisposition" IS NOT NULL + AND NEW."IdentityAnchorResolutionIntentAtUtc" IS NOT NULL; + + IF resolution_is_any <> resolution_is_complete THEN + RAISE EXCEPTION + 'Workspace Staff onboarding resolution coordinates must be recorded atomically'; + END IF; + + IF NOT resolution_was_empty + AND (NEW."IdentityAnchorResolutionEventId", + NEW."IdentityAnchorResolutionStaffMemberId", + NEW."IdentityAnchorResolutionApplicationVersion", + NEW."IdentityAnchorResolutionDisposition", + NEW."IdentityAnchorResolutionIntentAtUtc") IS DISTINCT FROM + (OLD."IdentityAnchorResolutionEventId", + OLD."IdentityAnchorResolutionStaffMemberId", + OLD."IdentityAnchorResolutionApplicationVersion", + OLD."IdentityAnchorResolutionDisposition", + OLD."IdentityAnchorResolutionIntentAtUtc") + THEN + RAISE EXCEPTION + 'Workspace Staff onboarding resolution coordinates are immutable'; + END IF; + + IF NEW."IdentityAnchorExpectedResolutionEventId" IS NOT NULL + AND NEW."IdentityAnchorContinuationEventId" IS NULL + AND NEW."IdentityAnchorResolutionEventId" IS NULL + THEN + RAISE EXCEPTION + 'Workspace Staff onboarding expected resolution requires continuation or terminal resolution'; + END IF; + + IF resolution_was_empty AND resolution_is_complete + AND (NEW."IdentityAnchorResolutionEventId" IS DISTINCT FROM + NEW."IdentityAnchorExpectedResolutionEventId" + OR NEW."IdentityAnchorResolutionStaffMemberId" IS DISTINCT FROM + NEW."StaffMemberId" + OR NEW."IdentityAnchorResolutionApplicationVersion" IS DISTINCT FROM + NEW."Version" + OR NEW."IdentityAnchorResolutionIntentAtUtc" IS DISTINCT FROM + NEW."LastChangedAtUtc") + THEN + RAISE EXCEPTION + 'Workspace Staff onboarding resolution does not match the exact terminal coordinates'; + END IF; + + IF NEW."IdentityAnchorContinuationEventId" IS NOT NULL + AND NEW."IdentityAnchorResolutionEventId" IS NULL + AND (NEW."Status" <> 4 + OR NEW."FailureCode" IS NOT NULL) + THEN + RAISE EXCEPTION + 'Workspace Staff onboarding continuation requires exact Staff-ready state'; + END IF; + + IF resolution_is_complete + AND NEW."FailureCode" IS NOT NULL + THEN + RAISE EXCEPTION + 'Workspace Staff onboarding terminal anchor resolution cannot retain a failure code'; + END IF; + + IF OLD."IdentityAnchorResolutionObservedAtUtc" IS NOT NULL + AND NEW."IdentityAnchorResolutionObservedAtUtc" IS DISTINCT FROM + OLD."IdentityAnchorResolutionObservedAtUtc" + THEN + RAISE EXCEPTION + 'Workspace Staff onboarding resolution observation is immutable'; + END IF; + + IF OLD."IdentityAnchorResolutionObservedAtUtc" IS NULL + AND NEW."IdentityAnchorResolutionObservedAtUtc" IS NOT NULL + AND (OLD."IdentityAnchorResolutionEventId" IS NULL + OR NEW."IdentityAnchorResolutionEventId" IS NULL + OR OLD.xmin::text::numeric = mod( + pg_current_xact_id()::text::numeric, + 4294967296) + OR NEW."IdentityAnchorResolutionObservedAtUtc" IS DISTINCT FROM + NEW."LastChangedAtUtc") + THEN + RAISE EXCEPTION + 'Workspace Staff onboarding resolution observation must match the exact observed transition'; + END IF; + + anchor_changed := + NEW."StaffMemberId" IS DISTINCT FROM OLD."StaffMemberId" + OR NEW."IdentityAnchorExpectedResolutionEventId" IS DISTINCT FROM + OLD."IdentityAnchorExpectedResolutionEventId" + OR NEW."IdentityAnchorContinuationEventId" IS DISTINCT FROM + OLD."IdentityAnchorContinuationEventId" + OR resolution_is_any <> NOT resolution_was_empty + OR NEW."IdentityAnchorResolutionObservedAtUtc" IS DISTINCT FROM + OLD."IdentityAnchorResolutionObservedAtUtc"; + IF anchor_changed AND NEW."Version" <> OLD."Version" + 1 THEN + RAISE EXCEPTION + 'Workspace Staff onboarding anchor transitions must advance exactly one version'; + END IF; + + RETURN NEW; + END; + $function$; + + CREATE TRIGGER + "TR_staff_onboarding_anchor_coordinates" + BEFORE INSERT OR UPDATE + ON "workspaces"."staff_onboarding_applications" + FOR EACH ROW + EXECUTE FUNCTION + "workspaces".enforce_staff_onboarding_anchor_coordinates(); + + CREATE FUNCTION + "workspaces".reject_staff_onboarding_delete_without_destroy() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + IF "workspaces".tenant_destroy_delete_admitted( + OLD."ScopeId", + 13) + THEN + RETURN OLD; + END IF; + + RAISE EXCEPTION + 'Workspace Staff onboarding applications may only be deleted by admitted tenant destruction stage 13'; + END; + $function$; + + CREATE TRIGGER + "TR_staff_onboarding_delete_guard" + BEFORE DELETE + ON "workspaces"."staff_onboarding_applications" + FOR EACH ROW + EXECUTE FUNCTION + "workspaces".reject_staff_onboarding_delete_without_destroy(); + + CREATE FUNCTION + "workspaces".reject_access_restoration_disposition_mutation() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + IF NEW."RestorationDisposition" IS DISTINCT FROM + OLD."RestorationDisposition" + THEN + RAISE EXCEPTION + 'Workspace Staff access restoration disposition is immutable'; + END IF; + RETURN NEW; + END; + $function$; + + CREATE TRIGGER + "TR_staff_access_process_restoration_disposition_immutable" + BEFORE UPDATE OF "RestorationDisposition" + ON "workspaces"."staff_access_processes" + FOR EACH ROW + EXECUTE FUNCTION + "workspaces".reject_access_restoration_disposition_mutation(); + + CREATE FUNCTION + "workspaces".enforce_suppressed_access_process_has_no_snapshots() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + IF NEW."RestorationDisposition" = 3 + AND EXISTS ( + SELECT 1 + FROM "workspaces"."staff_access_profile_snapshots" snapshot + WHERE snapshot."ProcessId" = NEW."Id") + THEN + RAISE EXCEPTION + 'Suppressed Workspace Staff access restoration cannot retain profile snapshots'; + END IF; + RETURN NULL; + END; + $function$; + + CREATE CONSTRAINT TRIGGER + "TR_staff_access_process_suppressed_snapshots" + AFTER INSERT OR UPDATE + ON "workspaces"."staff_access_processes" + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE FUNCTION + "workspaces".enforce_suppressed_access_process_has_no_snapshots(); + + CREATE FUNCTION + "workspaces".enforce_access_snapshot_parent_not_suppressed() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM "workspaces"."staff_access_processes" process + WHERE process."Id" = NEW."ProcessId" + AND process."RestorationDisposition" = 3) + THEN + RAISE EXCEPTION + 'Suppressed Workspace Staff access restoration cannot accept profile snapshots'; + END IF; + RETURN NULL; + END; + $function$; + + CREATE CONSTRAINT TRIGGER + "TR_staff_access_snapshot_parent_not_suppressed" + AFTER INSERT OR UPDATE + ON "workspaces"."staff_access_profile_snapshots" + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE FUNCTION + "workspaces".enforce_access_snapshot_parent_not_suppressed(); + """); + + migrationBuilder.Sql( + """ + CREATE FUNCTION + "workspaces".is_exactly_reviewed_staff_onboarding( + application "workspaces"."staff_onboarding_applications") + RETURNS boolean + LANGUAGE sql + STABLE + STRICT + AS $function$ + SELECT EXISTS ( + SELECT 1 + FROM "workspaces"."staff_historical_no_provision_receipts" receipt + WHERE receipt."ScopeId" = application."ScopeId" + AND receipt."ContractVersion" = 1 + AND receipt."ApplicationId" = application."Id" + AND receipt."SourceKind" = application."SourceKind" + AND receipt."SourceId" = application."SourceId" + AND receipt."ResultApplicationVersion" = application."Version" + AND receipt."ResultApplicationStatus" = application."Status" + AND receipt."ResultApplicationStatus" IN (5, 7, 8, 9, 10) + AND application."SubjectId" = + 'no-provision:' || receipt."Id"::text + AND application."VerifiedAccountEmail" IS NULL + AND application."DisplayName" IS NULL + AND application."LegalName" IS NULL + AND application."WorkEmail" IS NULL + AND application."WorkPhone" IS NULL + AND application."EmployeeNumber" IS NULL + AND application."JobTitle" IS NULL + AND application."Department" IS NULL + AND application."FailureCode" IS NULL + AND application."StaffMemberId" IS NULL + AND application."IdentityAnchorExpectedResolutionEventId" IS NULL + AND application."IdentityAnchorContinuationEventId" IS NULL + AND application."IdentityAnchorResolutionEventId" IS NULL + AND application."IdentityAnchorResolutionStaffMemberId" IS NULL + AND application."IdentityAnchorResolutionApplicationVersion" IS NULL + AND application."IdentityAnchorResolutionDisposition" IS NULL + AND application."IdentityAnchorResolutionIntentAtUtc" IS NULL + AND application."IdentityAnchorResolutionObservedAtUtc" IS NULL); + $function$; + + CREATE FUNCTION + "workspaces".workspace_anchor_sweep_empty_sha256( + cycle_id uuid, + run_id uuid) + RETURNS text + LANGUAGE sql + IMMUTABLE + STRICT + AS $function$ + SELECT encode( + sha256( + convert_to( + 'empty|' || cycle_id::text || '|' || + run_id::text, + 'UTF8')), + 'hex'); + $function$; + + CREATE FUNCTION + "workspaces".workspace_anchor_sweep_advance_sha256( + expected_version bigint, + cycle_id uuid, + expected_after_ordinal bigint, + next_after_ordinal bigint, + reached_end boolean, + run_id uuid, + scanned_count bigint, + no_anchor_count bigint, + removed_count bigint, + observed_count bigint, + already_observed_count bigint, + deferred_count bigint, + conflict_count bigint, + pass_one_committed_count bigint, + resolution_record_confirmed_count bigint) + RETURNS text + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT encode( + sha256( + convert_to( + expected_version::text || '|' || + cycle_id::text || '|' || + COALESCE( + expected_after_ordinal::text, + 'null') || '|' || + next_after_ordinal::text || '|' || + CASE WHEN reached_end THEN '1' ELSE '0' END || + '|' || run_id::text || '|' || + scanned_count::text || '|' || + no_anchor_count::text || '|' || + removed_count::text || '|' || + observed_count::text || '|' || + already_observed_count::text || '|' || + deferred_count::text || '|' || + conflict_count::text || '|' || + pass_one_committed_count::text || '|' || + resolution_record_confirmed_count::text, + 'UTF8')), + 'hex'); + $function$; + + CREATE FUNCTION + "workspaces".enforce_identity_anchor_sweep_checkpoint_mutation() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + last_completed_changed boolean; + all_cycle_counts_zero boolean; + all_last_counts_zero boolean; + expected_empty_digest text; + expected_advance_digest text; + eligible_count bigint; + last_eligible_ordinal bigint; + delta_scanned bigint; + delta_no_anchor bigint; + delta_removed bigint; + delta_observed bigint; + delta_already_observed bigint; + delta_deferred bigint; + delta_conflict bigint; + delta_pass_one bigint; + delta_resolution_confirmed bigint; + BEGIN + all_cycle_counts_zero := + NEW."CycleScannedCount" = 0 + AND NEW."CycleNoAnchorCount" = 0 + AND NEW."CycleRemovedCount" = 0 + AND NEW."CycleObservedCount" = 0 + AND NEW."CycleAlreadyObservedCount" = 0 + AND NEW."CycleDeferredCount" = 0 + AND NEW."CycleConflictCount" = 0 + AND NEW."CyclePassOneCommittedCount" = 0 + AND NEW."CycleResolutionRecordConfirmedCount" = 0; + all_last_counts_zero := + NEW."LastCompletedScannedCount" = 0 + AND NEW."LastCompletedNoAnchorCount" = 0 + AND NEW."LastCompletedRemovedCount" = 0 + AND NEW."LastCompletedObservedCount" = 0 + AND NEW."LastCompletedAlreadyObservedCount" = 0 + AND NEW."LastCompletedDeferredCount" = 0 + AND NEW."LastCompletedConflictCount" = 0 + AND NEW."LastCompletedPassOneCommittedCount" = 0 + AND NEW."LastCompletedResolutionRecordConfirmedCount" = 0; + + IF TG_OP = 'INSERT' THEN + IF NEW."ProtocolVersion" <> 1 + OR NEW."Id" = + '00000000-0000-0000-0000-000000000000'::uuid + OR btrim(NEW."ScopeId") = '' + OR NEW."UpdatedAtUtc" = + '0001-01-01 00:00:00+00'::timestamptz + OR NOT all_cycle_counts_zero + OR NOT all_last_counts_zero + THEN + RAISE EXCEPTION + 'Workspace Staff identity-anchor sweep checkpoint initial state is invalid'; + END IF; + + IF NEW."Version" = 1 + AND NEW."CycleId" IS NULL + AND NEW."CycleUpperOrdinal" IS NULL + AND NEW."AfterOrdinal" IS NULL + AND NEW."CycleStartedAtUtc" IS NULL + AND NEW."LastCompletedCycleId" IS NULL + AND NEW."LastCompletedUpperOrdinal" IS NULL + AND NEW."LastCompletedAtUtc" IS NULL + AND NEW."LastAdvanceId" IS NULL + AND NEW."LastAdvanceSha256" IS NULL + AND NEW."LastRunId" IS NULL + THEN + RETURN NEW; + END IF; + + IF NEW."Version" = 2 + AND NEW."CycleId" IS NOT NULL + AND NEW."CycleUpperOrdinal" > 0 + AND NEW."CycleUpperOrdinal" IS NOT DISTINCT FROM ( + SELECT max(application."IdentityAnchorSweepOrdinal") + FROM "workspaces"."staff_onboarding_applications" application + WHERE application."ScopeId" = NEW."ScopeId") + AND NEW."AfterOrdinal" IS NULL + AND NEW."CycleStartedAtUtc" IS NOT DISTINCT FROM + NEW."UpdatedAtUtc" + AND NEW."LastCompletedCycleId" IS NULL + AND NEW."LastCompletedUpperOrdinal" IS NULL + AND NEW."LastCompletedAtUtc" IS NULL + AND NEW."LastAdvanceId" IS NULL + AND NEW."LastAdvanceSha256" IS NULL + AND NEW."LastRunId" IS NOT NULL + THEN + RETURN NEW; + END IF; + + IF NEW."Version" = 2 + AND NEW."CycleId" IS NULL + AND NEW."CycleUpperOrdinal" IS NULL + AND NEW."AfterOrdinal" IS NULL + AND NEW."CycleStartedAtUtc" IS NULL + AND NEW."LastCompletedCycleId" IS NOT NULL + AND NEW."LastCompletedUpperOrdinal" IS NULL + AND NEW."LastCompletedAtUtc" IS NOT DISTINCT FROM + NEW."UpdatedAtUtc" + AND NEW."LastAdvanceId" IS NOT NULL + AND NEW."LastAdvanceSha256" IS NOT NULL + AND NEW."LastRunId" IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM "workspaces"."staff_onboarding_applications" application + WHERE application."ScopeId" = NEW."ScopeId") + THEN + expected_empty_digest := "workspaces". + workspace_anchor_sweep_empty_sha256( + NEW."LastCompletedCycleId", + NEW."LastRunId"); + IF NEW."LastAdvanceSha256" = expected_empty_digest THEN + RETURN NEW; + END IF; + END IF; + + RAISE EXCEPTION + 'Workspace Staff identity-anchor sweep checkpoint initial state is invalid'; + END IF; + + IF TG_OP = 'DELETE' THEN + IF "workspaces".tenant_destroy_delete_admitted( + OLD."ScopeId", + 20) + THEN + RETURN OLD; + END IF; + RAISE EXCEPTION + 'Workspace Staff identity-anchor sweep checkpoints may only be deleted by admitted tenant destruction stage 20'; + END IF; + + IF (NEW."Id", NEW."ScopeId", NEW."ProtocolVersion") IS DISTINCT FROM + (OLD."Id", OLD."ScopeId", OLD."ProtocolVersion") + OR NEW."Version" <> OLD."Version" + 1 + OR NEW."UpdatedAtUtc" < OLD."UpdatedAtUtc" + THEN + RAISE EXCEPTION + 'Workspace Staff identity-anchor sweep checkpoint identity or version is invalid'; + END IF; + + last_completed_changed := + (NEW."LastCompletedCycleId", + NEW."LastCompletedUpperOrdinal", + NEW."LastCompletedAtUtc", + NEW."LastCompletedScannedCount", + NEW."LastCompletedNoAnchorCount", + NEW."LastCompletedRemovedCount", + NEW."LastCompletedObservedCount", + NEW."LastCompletedAlreadyObservedCount", + NEW."LastCompletedDeferredCount", + NEW."LastCompletedConflictCount", + NEW."LastCompletedPassOneCommittedCount", + NEW."LastCompletedResolutionRecordConfirmedCount") IS DISTINCT FROM + (OLD."LastCompletedCycleId", + OLD."LastCompletedUpperOrdinal", + OLD."LastCompletedAtUtc", + OLD."LastCompletedScannedCount", + OLD."LastCompletedNoAnchorCount", + OLD."LastCompletedRemovedCount", + OLD."LastCompletedObservedCount", + OLD."LastCompletedAlreadyObservedCount", + OLD."LastCompletedDeferredCount", + OLD."LastCompletedConflictCount", + OLD."LastCompletedPassOneCommittedCount", + OLD."LastCompletedResolutionRecordConfirmedCount"); + + IF OLD."CycleId" IS NULL AND NEW."CycleId" IS NOT NULL THEN + IF NEW."AfterOrdinal" IS NOT NULL + OR NEW."CycleStartedAtUtc" IS DISTINCT FROM + NEW."UpdatedAtUtc" + OR NEW."CycleUpperOrdinal" IS DISTINCT FROM ( + SELECT max(application."IdentityAnchorSweepOrdinal") + FROM "workspaces"."staff_onboarding_applications" application + WHERE application."ScopeId" = NEW."ScopeId") + OR NEW."CycleScannedCount" <> 0 + OR last_completed_changed + OR NEW."LastAdvanceId" IS DISTINCT FROM OLD."LastAdvanceId" + OR NEW."LastAdvanceSha256" IS DISTINCT FROM OLD."LastAdvanceSha256" + THEN + RAISE EXCEPTION + 'Workspace Staff identity-anchor sweep begin transition is invalid'; + END IF; + ELSIF OLD."CycleId" IS NULL AND NEW."CycleId" IS NULL THEN + IF NOT last_completed_changed + OR NEW."LastCompletedUpperOrdinal" IS NOT NULL + OR NEW."LastCompletedAtUtc" IS DISTINCT FROM + NEW."UpdatedAtUtc" + OR NEW."LastCompletedScannedCount" <> 0 + OR NEW."LastAdvanceId" IS NOT DISTINCT FROM OLD."LastAdvanceId" + OR EXISTS ( + SELECT 1 + FROM "workspaces"."staff_onboarding_applications" application + WHERE application."ScopeId" = NEW."ScopeId") + OR NEW."LastAdvanceSha256" IS DISTINCT FROM + "workspaces".workspace_anchor_sweep_empty_sha256( + NEW."LastCompletedCycleId", + NEW."LastRunId") + THEN + RAISE EXCEPTION + 'Workspace Staff identity-anchor empty completion transition is invalid'; + END IF; + ELSIF OLD."CycleId" IS NOT NULL AND NEW."CycleId" IS NOT NULL THEN + IF (NEW."CycleId", NEW."CycleUpperOrdinal", NEW."CycleStartedAtUtc") + IS DISTINCT FROM + (OLD."CycleId", OLD."CycleUpperOrdinal", OLD."CycleStartedAtUtc") + OR NEW."AfterOrdinal" IS NULL + OR (OLD."AfterOrdinal" IS NOT NULL AND + NEW."AfterOrdinal" <= OLD."AfterOrdinal") + OR NEW."CycleScannedCount" < OLD."CycleScannedCount" + OR NEW."CycleNoAnchorCount" < OLD."CycleNoAnchorCount" + OR NEW."CycleRemovedCount" < OLD."CycleRemovedCount" + OR NEW."CycleObservedCount" < OLD."CycleObservedCount" + OR NEW."CycleAlreadyObservedCount" < OLD."CycleAlreadyObservedCount" + OR NEW."CycleDeferredCount" < OLD."CycleDeferredCount" + OR NEW."CycleConflictCount" < OLD."CycleConflictCount" + OR NEW."CyclePassOneCommittedCount" < OLD."CyclePassOneCommittedCount" + OR NEW."CycleResolutionRecordConfirmedCount" < + OLD."CycleResolutionRecordConfirmedCount" + OR last_completed_changed + OR NEW."LastAdvanceId" IS NOT DISTINCT FROM OLD."LastAdvanceId" + THEN + RAISE EXCEPTION + 'Workspace Staff identity-anchor sweep advance transition is invalid'; + END IF; + + delta_scanned := NEW."CycleScannedCount" - + OLD."CycleScannedCount"; + delta_no_anchor := NEW."CycleNoAnchorCount" - + OLD."CycleNoAnchorCount"; + delta_removed := NEW."CycleRemovedCount" - + OLD."CycleRemovedCount"; + delta_observed := NEW."CycleObservedCount" - + OLD."CycleObservedCount"; + delta_already_observed := + NEW."CycleAlreadyObservedCount" - + OLD."CycleAlreadyObservedCount"; + delta_deferred := NEW."CycleDeferredCount" - + OLD."CycleDeferredCount"; + delta_conflict := NEW."CycleConflictCount" - + OLD."CycleConflictCount"; + delta_pass_one := NEW."CyclePassOneCommittedCount" - + OLD."CyclePassOneCommittedCount"; + delta_resolution_confirmed := + NEW."CycleResolutionRecordConfirmedCount" - + OLD."CycleResolutionRecordConfirmedCount"; + + SELECT count(*) + INTO eligible_count + FROM "workspaces"."staff_onboarding_applications" application + WHERE application."ScopeId" = NEW."ScopeId" + AND application."IdentityAnchorSweepOrdinal" > + COALESCE(OLD."AfterOrdinal", 0) + AND application."IdentityAnchorSweepOrdinal" <= + NEW."AfterOrdinal" + AND NOT "workspaces". + is_exactly_reviewed_staff_onboarding(application); + + expected_advance_digest := "workspaces". + workspace_anchor_sweep_advance_sha256( + OLD."Version", + OLD."CycleId", + OLD."AfterOrdinal", + NEW."AfterOrdinal", + false, + NEW."LastRunId", + delta_scanned, + delta_no_anchor, + delta_removed, + delta_observed, + delta_already_observed, + delta_deferred, + delta_conflict, + delta_pass_one, + delta_resolution_confirmed); + IF NEW."AfterOrdinal" >= OLD."CycleUpperOrdinal" + OR delta_scanned <= 0 + OR delta_scanned <> eligible_count + OR delta_pass_one > delta_scanned + OR delta_resolution_confirmed > delta_scanned + OR NEW."LastAdvanceSha256" IS DISTINCT FROM + expected_advance_digest + OR NOT EXISTS ( + SELECT 1 + FROM "workspaces"."staff_onboarding_applications" application + WHERE application."ScopeId" = NEW."ScopeId" + AND application."IdentityAnchorSweepOrdinal" > + NEW."AfterOrdinal" + AND application."IdentityAnchorSweepOrdinal" <= + OLD."CycleUpperOrdinal" + AND NOT "workspaces". + is_exactly_reviewed_staff_onboarding(application)) + THEN + RAISE EXCEPTION + 'Workspace Staff identity-anchor sweep advance proof is invalid'; + END IF; + ELSIF OLD."CycleId" IS NOT NULL AND NEW."CycleId" IS NULL THEN + IF NEW."LastCompletedCycleId" IS DISTINCT FROM OLD."CycleId" + OR NEW."LastCompletedUpperOrdinal" IS DISTINCT FROM + OLD."CycleUpperOrdinal" + OR NEW."LastCompletedAtUtc" IS DISTINCT FROM NEW."UpdatedAtUtc" + OR NEW."LastCompletedScannedCount" < OLD."CycleScannedCount" + OR NEW."LastCompletedNoAnchorCount" < OLD."CycleNoAnchorCount" + OR NEW."LastCompletedRemovedCount" < OLD."CycleRemovedCount" + OR NEW."LastCompletedObservedCount" < OLD."CycleObservedCount" + OR NEW."LastCompletedAlreadyObservedCount" < + OLD."CycleAlreadyObservedCount" + OR NEW."LastCompletedDeferredCount" < OLD."CycleDeferredCount" + OR NEW."LastCompletedConflictCount" < OLD."CycleConflictCount" + OR NEW."LastCompletedPassOneCommittedCount" < + OLD."CyclePassOneCommittedCount" + OR NEW."LastCompletedResolutionRecordConfirmedCount" < + OLD."CycleResolutionRecordConfirmedCount" + OR NEW."CycleScannedCount" <> 0 + OR NEW."LastAdvanceId" IS NOT DISTINCT FROM OLD."LastAdvanceId" + THEN + RAISE EXCEPTION + 'Workspace Staff identity-anchor sweep completion transition is invalid'; + END IF; + + delta_scanned := NEW."LastCompletedScannedCount" - + OLD."CycleScannedCount"; + delta_no_anchor := NEW."LastCompletedNoAnchorCount" - + OLD."CycleNoAnchorCount"; + delta_removed := NEW."LastCompletedRemovedCount" - + OLD."CycleRemovedCount"; + delta_observed := NEW."LastCompletedObservedCount" - + OLD."CycleObservedCount"; + delta_already_observed := + NEW."LastCompletedAlreadyObservedCount" - + OLD."CycleAlreadyObservedCount"; + delta_deferred := NEW."LastCompletedDeferredCount" - + OLD."CycleDeferredCount"; + delta_conflict := NEW."LastCompletedConflictCount" - + OLD."CycleConflictCount"; + delta_pass_one := + NEW."LastCompletedPassOneCommittedCount" - + OLD."CyclePassOneCommittedCount"; + delta_resolution_confirmed := + NEW."LastCompletedResolutionRecordConfirmedCount" - + OLD."CycleResolutionRecordConfirmedCount"; + + SELECT count(*), + max(application."IdentityAnchorSweepOrdinal") + INTO eligible_count, last_eligible_ordinal + FROM "workspaces"."staff_onboarding_applications" application + WHERE application."ScopeId" = NEW."ScopeId" + AND application."IdentityAnchorSweepOrdinal" > + COALESCE(OLD."AfterOrdinal", 0) + AND application."IdentityAnchorSweepOrdinal" <= + OLD."CycleUpperOrdinal" + AND NOT "workspaces". + is_exactly_reviewed_staff_onboarding(application); + last_eligible_ordinal := COALESCE( + last_eligible_ordinal, + OLD."CycleUpperOrdinal"); + + expected_advance_digest := "workspaces". + workspace_anchor_sweep_advance_sha256( + OLD."Version", + OLD."CycleId", + OLD."AfterOrdinal", + last_eligible_ordinal, + true, + NEW."LastRunId", + delta_scanned, + delta_no_anchor, + delta_removed, + delta_observed, + delta_already_observed, + delta_deferred, + delta_conflict, + delta_pass_one, + delta_resolution_confirmed); + IF delta_scanned <> eligible_count + OR delta_pass_one > delta_scanned + OR delta_resolution_confirmed > delta_scanned + OR NEW."LastAdvanceSha256" IS DISTINCT FROM + expected_advance_digest + THEN + RAISE EXCEPTION + 'Workspace Staff identity-anchor sweep completion proof is invalid'; + END IF; + ELSE + RAISE EXCEPTION + 'Workspace Staff identity-anchor sweep checkpoint transition is invalid'; + END IF; + + IF NEW."LastRunId" IS NULL THEN + RAISE EXCEPTION + 'Workspace Staff identity-anchor sweep checkpoint transition requires a run coordinate'; + END IF; + RETURN NEW; + END; + $function$; + + CREATE TRIGGER + "TR_workspace_staff_identity_anchor_sweep_checkpoint_mutation" + BEFORE INSERT OR UPDATE OR DELETE + ON "workspaces"."workspace_staff_identity_anchor_sweep_checkpoints" + FOR EACH ROW + EXECUTE FUNCTION + "workspaces".enforce_identity_anchor_sweep_checkpoint_mutation(); + + CREATE FUNCTION "workspaces".dotnet_string_length(value text) + RETURNS integer + LANGUAGE sql + IMMUTABLE + STRICT + AS $function$ + SELECT COALESCE( + sum(CASE WHEN ascii(ch) > 65535 THEN 2 ELSE 1 END), + 0)::integer + FROM regexp_split_to_table(value, '') AS parts(ch); + $function$; + + CREATE FUNCTION "workspaces".dotnet_length_prefix(value text) + RETURNS text + LANGUAGE sql + IMMUTABLE + STRICT + AS $function$ + SELECT "workspaces".dotnet_string_length(value)::text || + ':' || value; + $function$; + + CREATE FUNCTION + "workspaces".historical_no_provision_canonical_sha256( + receipt "workspaces"."staff_historical_no_provision_receipts") + RETURNS text + LANGUAGE plpgsql + STABLE + STRICT + AS $function$ + DECLARE + canonical text; + reviewed_at text; + BEGIN + reviewed_at := to_char( + receipt."ReviewedAtUtc" AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.US') || '0+00:00'; + canonical := + "workspaces".dotnet_length_prefix( + 'workspaces-staff-historical-no-provision|v1') || + "workspaces".dotnet_length_prefix( + receipt."ContractVersion"::text) || + "workspaces".dotnet_length_prefix( + replace(receipt."Id"::text, '-', '')) || + "workspaces".dotnet_length_prefix(receipt."ScopeId") || + "workspaces".dotnet_length_prefix( + replace(receipt."OperationId"::text, '-', '')) || + "workspaces".dotnet_length_prefix( + replace(receipt."ApplicationId"::text, '-', '')) || + "workspaces".dotnet_length_prefix( + receipt."SourceKind"::text) || + "workspaces".dotnet_length_prefix( + replace(receipt."SourceId"::text, '-', '')) || + "workspaces".dotnet_length_prefix( + receipt."ExpectedApplicationVersion"::text) || + "workspaces".dotnet_length_prefix( + receipt."ExpectedApplicationStatus"::text) || + "workspaces".dotnet_length_prefix( + receipt."ResultApplicationVersion"::text) || + "workspaces".dotnet_length_prefix( + receipt."ResultApplicationStatus"::text) || + "workspaces".dotnet_length_prefix( + receipt."OrganizationsScopeRevision"::text) || + "workspaces".dotnet_length_prefix( + receipt."OrganizationsSourceVersion"::text) || + "workspaces".dotnet_length_prefix( + receipt."OrganizationsSourceStatus"::text) || + "workspaces".dotnet_length_prefix( + receipt."StaffEvidenceSha256") || + "workspaces".dotnet_length_prefix( + replace( + receipt."ExternalEvidenceManifestId"::text, + '-', + '')) || + "workspaces".dotnet_length_prefix( + receipt."ExternalEvidenceSha256") || + "workspaces".dotnet_length_prefix( + receipt."ReviewerId") || + "workspaces".dotnet_length_prefix(reviewed_at); + RETURN encode( + sha256(convert_to(canonical, 'UTF8')), + 'hex'); + END; + $function$; + + CREATE FUNCTION + "workspaces".historical_no_provision_application_matches( + receipt "workspaces"."staff_historical_no_provision_receipts") + RETURNS boolean + LANGUAGE sql + STABLE + STRICT + AS $function$ + SELECT EXISTS ( + SELECT 1 + FROM "workspaces"."staff_onboarding_applications" application + WHERE application."ScopeId" = receipt."ScopeId" + AND application."Id" = receipt."ApplicationId" + AND application."SourceKind" = receipt."SourceKind" + AND application."SourceId" = receipt."SourceId" + AND application."Version" = + receipt."ResultApplicationVersion" + AND application."Status" = + receipt."ResultApplicationStatus" + AND receipt."ReviewedAtUtc" >= + application."LastChangedAtUtc" + AND application."SubjectId" = + 'no-provision:' || receipt."Id"::text + AND application."VerifiedAccountEmail" IS NULL + AND application."DisplayName" IS NULL + AND application."LegalName" IS NULL + AND application."WorkEmail" IS NULL + AND application."WorkPhone" IS NULL + AND application."EmployeeNumber" IS NULL + AND application."JobTitle" IS NULL + AND application."Department" IS NULL + AND application."FailureCode" IS NULL + AND application."StaffMemberId" IS NULL + AND application."IdentityAnchorExpectedResolutionEventId" IS NULL + AND application."IdentityAnchorContinuationEventId" IS NULL + AND application."IdentityAnchorResolutionEventId" IS NULL + AND application."IdentityAnchorResolutionStaffMemberId" IS NULL + AND application."IdentityAnchorResolutionApplicationVersion" IS NULL + AND application."IdentityAnchorResolutionDisposition" IS NULL + AND application."IdentityAnchorResolutionIntentAtUtc" IS NULL + AND application."IdentityAnchorResolutionObservedAtUtc" IS NULL); + $function$; + + CREATE FUNCTION + "workspaces".validate_historical_no_provision_receipt() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + PERFORM 1 + FROM "workspaces"."staff_onboarding_applications" application + WHERE application."ScopeId" = NEW."ScopeId" + AND application."Id" = NEW."ApplicationId" + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION + 'Workspace historical no-provision receipt application is unavailable'; + END IF; + + IF "workspaces".dotnet_string_length(NEW."ReviewerId") + NOT BETWEEN 1 AND 256 + OR NEW."CanonicalSha256" IS DISTINCT FROM + "workspaces".historical_no_provision_canonical_sha256(NEW) + OR NOT "workspaces".historical_no_provision_application_matches(NEW) + THEN + RAISE EXCEPTION + 'Workspace historical no-provision receipt does not match its canonical proof and exact application result'; + END IF; + RETURN NULL; + END; + $function$; + + CREATE CONSTRAINT TRIGGER + "TR_staff_historical_no_provision_receipt_integrity" + AFTER INSERT + ON "workspaces"."staff_historical_no_provision_receipts" + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE FUNCTION + "workspaces".validate_historical_no_provision_receipt(); + + CREATE TRIGGER + "TR_staff_historical_no_provision_receipts_append_only" + BEFORE UPDATE OR DELETE + ON "workspaces"."staff_historical_no_provision_receipts" + FOR EACH ROW + EXECUTE FUNCTION "workspaces".prevent_receipt_mutation(); + + CREATE FUNCTION + "workspaces".enforce_historical_no_provision_application_result() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM "workspaces"."staff_historical_no_provision_receipts" receipt + WHERE receipt."ScopeId" = NEW."ScopeId" + AND receipt."ApplicationId" = NEW."Id" + AND NOT "workspaces". + historical_no_provision_application_matches(receipt)) + THEN + RAISE EXCEPTION + 'Workspace historical no-provision application result cannot diverge or be resurrected'; + END IF; + RETURN NULL; + END; + $function$; + + CREATE CONSTRAINT TRIGGER + "TR_staff_onboarding_historical_no_provision_result" + AFTER INSERT OR UPDATE + ON "workspaces"."staff_onboarding_applications" + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE FUNCTION + "workspaces".enforce_historical_no_provision_application_result(); + + DO $validate$ + DECLARE + sequence_name text; + BEGIN + sequence_name := pg_get_serial_sequence( + 'workspaces.staff_onboarding_applications', + 'IdentityAnchorSweepOrdinal'); + IF sequence_name IS NULL + OR EXISTS ( + SELECT 1 + FROM "workspaces"."staff_onboarding_applications" + WHERE "IdentityAnchorSweepOrdinal" <= 0) + THEN + RAISE EXCEPTION + 'Workspaces identity-anchor sweep ordinal installation is invalid'; + END IF; + END; + $validate$; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + SET LOCAL lock_timeout = '1ms'; + + LOCK TABLE "workspaces"."inbox_messages" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."outbox_messages" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_access_processes" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_access_profile_snapshots" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_correlation_anonymisation_receipts" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_correlation_anonymisation_restore_receipts" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_deferred_claim_withdrawals" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_historical_no_provision_receipts" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_onboarding_applications" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_onboarding_correction_receipts" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_onboarding_processing_restriction_receipts" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."staff_retention_correlation_receipts" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."tenant_destroy_operations" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."tenant_destroy_receipts" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."workspace_staff_identity_anchor_sweep_checkpoints" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."workspace_termination_fence_receipts" + IN ACCESS EXCLUSIVE MODE NOWAIT; + LOCK TABLE "workspaces"."workspace_termination_fences" + IN ACCESS EXCLUSIVE MODE NOWAIT; + + DO $guard$ + DECLARE + active_tasks boolean := false; + BEGIN + IF to_regclass('tasks.task_runs') IS NOT NULL THEN + EXECUTE + 'LOCK TABLE "tasks"."task_runs" ' || + 'IN SHARE MODE NOWAIT'; + END IF; + + PERFORM set_config('lock_timeout', '0', true); + + IF to_regclass('tasks.task_runs') IS NOT NULL THEN + EXECUTE + 'SELECT EXISTS (' || + 'SELECT 1 FROM "tasks"."task_runs" ' || + 'WHERE "ModuleName" = ''workspaces'' ' || + 'AND "TaskName" = ' || + '''reconcile-staff-identity-anchors'' ' || + 'AND "Status" IN (1, 2, 3, 4, 5, 8))' + INTO active_tasks; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "workspaces"."staff_historical_no_provision_receipts") + THEN + RAISE EXCEPTION + 'Cannot downgrade Workspaces identity anchors while historical no-provision receipts exist'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "workspaces"."workspace_staff_identity_anchor_sweep_checkpoints") + THEN + RAISE EXCEPTION + 'Cannot downgrade Workspaces identity anchors while sweep checkpoints exist'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "workspaces"."tenant_destroy_operations" + WHERE "Stage" IN (20, 21)) + THEN + RAISE EXCEPTION + 'Cannot downgrade Workspaces identity anchors while tenant destruction is in a new active stage'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "workspaces"."staff_onboarding_applications" + WHERE "IdentityAnchorExpectedResolutionEventId" IS NOT NULL + OR "IdentityAnchorContinuationEventId" IS NOT NULL + OR "IdentityAnchorResolutionEventId" IS NOT NULL + OR "IdentityAnchorResolutionStaffMemberId" IS NOT NULL + OR "IdentityAnchorResolutionApplicationVersion" IS NOT NULL + OR "IdentityAnchorResolutionDisposition" IS NOT NULL + OR "IdentityAnchorResolutionIntentAtUtc" IS NOT NULL + OR "IdentityAnchorResolutionObservedAtUtc" IS NOT NULL) + THEN + RAISE EXCEPTION + 'Cannot downgrade Workspaces identity anchors while onboarding anchor coordinates exist'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "workspaces"."staff_access_processes" + WHERE "RestorationDisposition" = 3) + THEN + RAISE EXCEPTION + 'Cannot downgrade Workspaces identity anchors while suppressed access restorations exist'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "workspaces"."staff_onboarding_applications" + WHERE "StaffMemberId" IS NOT NULL + AND "Status" IN (1, 2, 3, 4, 6) + AND "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) + THEN + RAISE EXCEPTION + 'Cannot downgrade Workspaces identity anchors after irreversible bound nonterminal applicant redaction'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "workspaces"."outbox_messages" + WHERE "ProcessedAtUtc" IS NULL + AND "EventType" IN ( + 'BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent', + 'BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent', + 'BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCreatedIntegrationEvent', + 'workspace-staff-onboarding-identity-anchor-continuation-requested', + 'workspace-staff-onboarding-identity-anchor-resolved', + 'workspace-onboarding-identity-anchor-created')) + OR EXISTS ( + SELECT 1 + FROM "workspaces"."inbox_messages" + WHERE "Status" IN (1, 2, 4) + AND "EventType" IN ( + 'BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent', + 'BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent', + 'BunkFy.Modules.Staff.Contracts.StaffIdentityProvisioningAnchorCreatedIntegrationEvent', + 'workspace-staff-onboarding-identity-anchor-continuation-requested', + 'workspace-staff-onboarding-identity-anchor-resolved', + 'workspace-onboarding-identity-anchor-created')) + THEN + RAISE EXCEPTION + 'Cannot downgrade Workspaces identity anchors while continuation or resolution messages are pending'; + END IF; + + IF active_tasks THEN + RAISE EXCEPTION + 'Cannot downgrade Workspaces identity anchors while reconciliation tasks are active'; + END IF; + END; + $guard$; + + DROP TRIGGER IF EXISTS + "TR_staff_onboarding_historical_no_provision_result" + ON "workspaces"."staff_onboarding_applications"; + DROP FUNCTION IF EXISTS + "workspaces".enforce_historical_no_provision_application_result(); + + DROP TRIGGER IF EXISTS + "TR_staff_historical_no_provision_receipt_integrity" + ON "workspaces"."staff_historical_no_provision_receipts"; + DROP FUNCTION IF EXISTS + "workspaces".validate_historical_no_provision_receipt(); + + DROP TRIGGER IF EXISTS + "TR_staff_historical_no_provision_receipts_append_only" + ON "workspaces"."staff_historical_no_provision_receipts"; + + DROP FUNCTION IF EXISTS + "workspaces".historical_no_provision_application_matches( + "workspaces"."staff_historical_no_provision_receipts"); + DROP FUNCTION IF EXISTS + "workspaces".historical_no_provision_canonical_sha256( + "workspaces"."staff_historical_no_provision_receipts"); + DROP FUNCTION IF EXISTS + "workspaces".dotnet_length_prefix(text); + DROP FUNCTION IF EXISTS + "workspaces".dotnet_string_length(text); + + DROP TRIGGER IF EXISTS + "TR_workspace_staff_identity_anchor_sweep_checkpoint_mutation" + ON "workspaces"."workspace_staff_identity_anchor_sweep_checkpoints"; + DROP FUNCTION IF EXISTS + "workspaces".enforce_identity_anchor_sweep_checkpoint_mutation(); + DROP FUNCTION IF EXISTS + "workspaces".workspace_anchor_sweep_advance_sha256( + bigint, uuid, bigint, bigint, boolean, uuid, + bigint, bigint, bigint, bigint, bigint, bigint, + bigint, bigint, bigint); + DROP FUNCTION IF EXISTS + "workspaces".workspace_anchor_sweep_empty_sha256(uuid, uuid); + DROP FUNCTION IF EXISTS + "workspaces".is_exactly_reviewed_staff_onboarding( + "workspaces"."staff_onboarding_applications"); + + DROP TRIGGER IF EXISTS + "TR_staff_access_snapshot_parent_not_suppressed" + ON "workspaces"."staff_access_profile_snapshots"; + DROP FUNCTION IF EXISTS + "workspaces".enforce_access_snapshot_parent_not_suppressed(); + DROP TRIGGER IF EXISTS + "TR_staff_access_process_suppressed_snapshots" + ON "workspaces"."staff_access_processes"; + DROP FUNCTION IF EXISTS + "workspaces".enforce_suppressed_access_process_has_no_snapshots(); + DROP TRIGGER IF EXISTS + "TR_staff_access_process_restoration_disposition_immutable" + ON "workspaces"."staff_access_processes"; + DROP FUNCTION IF EXISTS + "workspaces".reject_access_restoration_disposition_mutation(); + + DROP TRIGGER IF EXISTS + "TR_staff_onboarding_delete_guard" + ON "workspaces"."staff_onboarding_applications"; + DROP FUNCTION IF EXISTS + "workspaces".reject_staff_onboarding_delete_without_destroy(); + DROP TRIGGER IF EXISTS + "TR_staff_onboarding_anchor_coordinates" + ON "workspaces"."staff_onboarding_applications"; + DROP FUNCTION IF EXISTS + "workspaces".enforce_staff_onboarding_anchor_coordinates(); + DROP TRIGGER IF EXISTS + "TR_staff_onboarding_sweep_ordinal" + ON "workspaces"."staff_onboarding_applications"; + DROP FUNCTION IF EXISTS + "workspaces".enforce_staff_onboarding_sweep_ordinal(); + + DROP TRIGGER IF EXISTS + "TR_staff_onboarding_correction_receipts_append_only" + ON "workspaces"."staff_onboarding_correction_receipts"; + + CREATE OR REPLACE FUNCTION + "workspaces".prevent_receipt_mutation() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + destroy_operation_id text; + BEGIN + destroy_operation_id := current_setting( + 'bunkfy.workspaces_tenant_destroy_operation_id', + true); + IF TG_OP = 'DELETE' AND + destroy_operation_id IS NOT NULL AND + EXISTS ( + SELECT 1 + FROM "workspaces"."tenant_destroy_operations" operation + INNER JOIN + "workspaces"."workspace_termination_fences" fence + ON fence."ScopeId" = operation."ScopeId" + AND fence."Id" = operation."FenceId" + WHERE operation."OperationId"::text = + destroy_operation_id + AND operation."ScopeId" = OLD."ScopeId" + AND fence."State" = 2) + THEN + RETURN OLD; + END IF; + + RAISE EXCEPTION 'workspace receipts are append-only'; + END; + $function$; + + DROP FUNCTION IF EXISTS + "workspaces".tenant_destroy_delete_admitted(text, integer); + + UPDATE "workspaces"."tenant_destroy_operations" + SET "Stage" = 20 + WHERE "Stage" = 22; + """); + + migrationBuilder.DropTable( + name: "staff_historical_no_provision_receipts", + schema: "workspaces"); + + migrationBuilder.DropTable( + name: "workspace_staff_identity_anchor_sweep_checkpoints", + schema: "workspaces"); + + migrationBuilder.DropCheckConstraint( + name: "CK_workspaces_tenant_destroy_operation_progress", + schema: "workspaces", + table: "tenant_destroy_operations"); + + migrationBuilder.DropIndex( + name: "IX_staff_onboarding_applications_IdentityAnchorContinuationEve~", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropIndex( + name: "IX_staff_onboarding_applications_IdentityAnchorExpectedResolut~", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropIndex( + name: "IX_staff_onboarding_applications_ScopeId_IdentityAnchorSweepOr~", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_onboarding_anchor_bound_redaction", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_onboarding_anchor_expected_resolution", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_onboarding_anchor_resolution_coordinates", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_onboarding_anchor_resolution_intent", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_onboarding_anchor_resolution_observation", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_onboarding_anchor_resolution_terminal", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_onboarding_identity_anchor_sweep_ordinal", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_onboarding_pending_profile", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_onboarding_staff", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_onboarding_terminal_redaction", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropCheckConstraint( + name: "CK_staff_access_process_restoration_disposition", + schema: "workspaces", + table: "staff_access_processes"); + + migrationBuilder.DropColumn( + name: "IdentityAnchorContinuationEventId", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropColumn( + name: "IdentityAnchorExpectedResolutionEventId", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropColumn( + name: "IdentityAnchorResolutionApplicationVersion", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropColumn( + name: "IdentityAnchorResolutionDisposition", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropColumn( + name: "IdentityAnchorResolutionEventId", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropColumn( + name: "IdentityAnchorResolutionIntentAtUtc", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropColumn( + name: "IdentityAnchorResolutionObservedAtUtc", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropColumn( + name: "IdentityAnchorResolutionStaffMemberId", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropColumn( + name: "IdentityAnchorSweepOrdinal", + schema: "workspaces", + table: "staff_onboarding_applications"); + + migrationBuilder.DropColumn( + name: "RestorationDisposition", + schema: "workspaces", + table: "staff_access_processes"); + + migrationBuilder.AddCheckConstraint( + name: "CK_workspaces_tenant_destroy_operation_progress", + schema: "workspaces", + table: "tenant_destroy_operations", + sql: "\"Stage\" BETWEEN 1 AND 20 AND \"RemovedRecordCount\" >= 0 AND \"CompletedBatchCount\" >= 0 AND \"ProofVersion\" = 1 AND \"ConcurrencyVersion\" >= 1"); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_onboarding_pending_profile", + schema: "workspaces", + table: "staff_onboarding_applications", + sql: "\"Status\" IN (5, 7, 8, 9, 10) OR (\"VerifiedAccountEmail\" IS NOT NULL AND \"DisplayName\" IS NOT NULL)"); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_onboarding_staff", + schema: "workspaces", + table: "staff_onboarding_applications", + sql: "\"Status\" NOT IN (4, 5) OR \"StaffMemberId\" IS NOT NULL"); + + migrationBuilder.AddCheckConstraint( + name: "CK_staff_onboarding_terminal_redaction", + schema: "workspaces", + table: "staff_onboarding_applications", + sql: "\"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)"); + } + } +} 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..39a86add 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 @@ -866,6 +866,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("RestorationDisposition") + .HasColumnType("integer"); + b.Property("ScopeId") .IsRequired() .HasMaxLength(128) @@ -907,6 +910,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("staff_access_processes", "workspaces", t => { + t.HasCheckConstraint("CK_staff_access_process_restoration_disposition", "(\"TargetState\" = 1 AND \"RestorationDisposition\" IN (2, 3)) OR (\"TargetState\" IN (2, 3) AND \"RestorationDisposition\" = 1)"); + t.HasCheckConstraint("CK_staff_access_process_staff_version", "\"TargetStaffVersion\" >= 2"); t.HasCheckConstraint("CK_staff_access_process_state", "\"State\" BETWEEN 1 AND 4"); @@ -917,6 +922,298 @@ 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", "Id"); + + b.HasIndex("ScopeId", "EnrollmentLinkId", "ClaimVersion"); + + 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.WorkspaceStaffHistoricalNoProvisionReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("CanonicalSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("ExpectedApplicationStatus") + .HasColumnType("integer"); + + b.Property("ExpectedApplicationVersion") + .HasColumnType("bigint"); + + b.Property("ExternalEvidenceManifestId") + .HasColumnType("uuid"); + + b.Property("ExternalEvidenceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("OrganizationsScopeRevision") + .HasColumnType("bigint"); + + b.Property("OrganizationsSourceStatus") + .HasColumnType("integer"); + + b.Property("OrganizationsSourceVersion") + .HasColumnType("bigint"); + + b.Property("ResultApplicationStatus") + .HasColumnType("integer"); + + b.Property("ResultApplicationVersion") + .HasColumnType("bigint"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewerId") + .IsRequired() + .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("StaffEvidenceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "ApplicationId") + .IsUnique(); + + b.HasIndex("ScopeId", "ExternalEvidenceManifestId"); + + b.HasIndex("ScopeId", "OperationId") + .IsUnique(); + + b.HasIndex("ScopeId", "SourceKind", "SourceId"); + + b.ToTable("staff_historical_no_provision_receipts", "workspaces", t => + { + t.HasCheckConstraint("CK_ws_hist_no_prov_authority", "((\"SourceKind\" = 1 AND \"OrganizationsSourceStatus\" IN (3, 4, 5)) OR (\"SourceKind\" = 2 AND \"OrganizationsSourceStatus\" IN (7, 8, 9)))"); + + t.HasCheckConstraint("CK_ws_hist_no_prov_contract", "\"ContractVersion\" = 1"); + + t.HasCheckConstraint("CK_ws_hist_no_prov_hashes", "\"StaffEvidenceSha256\" ~ '^[0-9a-f]{64}$' AND \"ExternalEvidenceSha256\" ~ '^[0-9a-f]{64}$' AND \"CanonicalSha256\" ~ '^[0-9a-f]{64}$'"); + + t.HasCheckConstraint("CK_ws_hist_no_prov_identifiers", "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"OperationId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ApplicationId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"SourceId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ExternalEvidenceManifestId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_ws_hist_no_prov_reviewer", "char_length(\"ReviewerId\") BETWEEN 1 AND 256 AND \"ReviewerId\" = btrim(\"ReviewerId\") AND \"ReviewerId\" !~ '[[:cntrl:]]'"); + + t.HasCheckConstraint("CK_ws_hist_no_prov_transition", "((\"ExpectedApplicationStatus\" IN (5, 7, 8, 9, 10) AND \"ResultApplicationStatus\" = \"ExpectedApplicationStatus\") OR (\"ExpectedApplicationStatus\" IN (1, 2, 3, 4, 6) AND \"ResultApplicationStatus\" = 8 AND \"ResultApplicationVersion\" = \"ExpectedApplicationVersion\" + 1))"); + + t.HasCheckConstraint("CK_ws_hist_no_prov_versions", "\"ExpectedApplicationVersion\" >= 1 AND \"ResultApplicationVersion\" >= \"ExpectedApplicationVersion\" AND \"ResultApplicationVersion\" <= \"ExpectedApplicationVersion\" + 1 AND \"OrganizationsScopeRevision\" >= 0 AND \"OrganizationsSourceVersion\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AfterOrdinal") + .HasColumnType("bigint"); + + b.Property("CycleAlreadyObservedCount") + .HasColumnType("bigint"); + + b.Property("CycleConflictCount") + .HasColumnType("bigint"); + + b.Property("CycleDeferredCount") + .HasColumnType("bigint"); + + b.Property("CycleId") + .HasColumnType("uuid"); + + b.Property("CycleNoAnchorCount") + .HasColumnType("bigint"); + + b.Property("CycleObservedCount") + .HasColumnType("bigint"); + + b.Property("CyclePassOneCommittedCount") + .HasColumnType("bigint"); + + b.Property("CycleRemovedCount") + .HasColumnType("bigint"); + + b.Property("CycleResolutionRecordConfirmedCount") + .HasColumnType("bigint"); + + b.Property("CycleScannedCount") + .HasColumnType("bigint"); + + b.Property("CycleStartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CycleUpperOrdinal") + .HasColumnType("bigint"); + + b.Property("LastAdvanceId") + .HasColumnType("uuid"); + + b.Property("LastAdvanceSha256") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastCompletedAlreadyObservedCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastCompletedConflictCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedCycleId") + .HasColumnType("uuid"); + + b.Property("LastCompletedDeferredCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedNoAnchorCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedObservedCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedPassOneCommittedCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedRemovedCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedResolutionRecordConfirmedCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedScannedCount") + .HasColumnType("bigint"); + + b.Property("LastCompletedUpperOrdinal") + .HasColumnType("bigint"); + + b.Property("LastRunId") + .HasColumnType("uuid"); + + b.Property("ProtocolVersion") + .HasColumnType("integer"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "LastCompletedAtUtc"); + + b.HasIndex("ScopeId", "ProtocolVersion") + .IsUnique(); + + b.ToTable("workspace_staff_identity_anchor_sweep_checkpoints", "workspaces", t => + { + t.HasCheckConstraint("CK_ws_anchor_sweep_cycle", "(\"CycleId\" IS NULL AND \"CycleUpperOrdinal\" IS NULL AND \"AfterOrdinal\" IS NULL AND \"CycleStartedAtUtc\" IS NULL AND \"CycleScannedCount\" = 0) OR (\"CycleId\" IS NOT NULL AND \"CycleUpperOrdinal\" > 0 AND (\"AfterOrdinal\" IS NULL OR (\"AfterOrdinal\" > 0 AND \"AfterOrdinal\" <= \"CycleUpperOrdinal\")) AND \"CycleStartedAtUtc\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_cycle_counts_nonnegative", "\"CycleScannedCount\" >= 0 AND \"CycleNoAnchorCount\" >= 0 AND \"CycleRemovedCount\" >= 0 AND \"CycleObservedCount\" >= 0 AND \"CycleAlreadyObservedCount\" >= 0 AND \"CycleDeferredCount\" >= 0 AND \"CycleConflictCount\" >= 0 AND \"CyclePassOneCommittedCount\" >= 0 AND \"CycleResolutionRecordConfirmedCount\" >= 0"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_cycle_counts_partition", "\"CycleScannedCount\" = \"CycleNoAnchorCount\" + \"CycleRemovedCount\" + \"CycleObservedCount\" + \"CycleAlreadyObservedCount\" + \"CycleDeferredCount\" + \"CycleConflictCount\" AND \"CyclePassOneCommittedCount\" <= \"CycleScannedCount\" AND \"CycleResolutionRecordConfirmedCount\" <= \"CycleScannedCount\""); + + t.HasCheckConstraint("CK_ws_anchor_sweep_identifiers", "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid AND (\"CycleId\" IS NULL OR \"CycleId\" <> '00000000-0000-0000-0000-000000000000'::uuid) AND (\"LastCompletedCycleId\" IS NULL OR \"LastCompletedCycleId\" <> '00000000-0000-0000-0000-000000000000'::uuid) AND (\"LastAdvanceId\" IS NULL OR \"LastAdvanceId\" <> '00000000-0000-0000-0000-000000000000'::uuid) AND (\"LastRunId\" IS NULL OR \"LastRunId\" <> '00000000-0000-0000-0000-000000000000'::uuid)"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_last_advance", "(\"LastAdvanceId\" IS NULL AND \"LastAdvanceSha256\" IS NULL) OR (\"LastAdvanceId\" IS NOT NULL AND \"LastAdvanceSha256\" ~ '^[0-9a-f]{64}$')"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_last_counts_nonnegative", "\"LastCompletedScannedCount\" >= 0 AND \"LastCompletedNoAnchorCount\" >= 0 AND \"LastCompletedRemovedCount\" >= 0 AND \"LastCompletedObservedCount\" >= 0 AND \"LastCompletedAlreadyObservedCount\" >= 0 AND \"LastCompletedDeferredCount\" >= 0 AND \"LastCompletedConflictCount\" >= 0 AND \"LastCompletedPassOneCommittedCount\" >= 0 AND \"LastCompletedResolutionRecordConfirmedCount\" >= 0"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_last_counts_partition", "\"LastCompletedScannedCount\" = \"LastCompletedNoAnchorCount\" + \"LastCompletedRemovedCount\" + \"LastCompletedObservedCount\" + \"LastCompletedAlreadyObservedCount\" + \"LastCompletedDeferredCount\" + \"LastCompletedConflictCount\" AND \"LastCompletedPassOneCommittedCount\" <= \"LastCompletedScannedCount\" AND \"LastCompletedResolutionRecordConfirmedCount\" <= \"LastCompletedScannedCount\""); + + t.HasCheckConstraint("CK_ws_anchor_sweep_last_cycle", "(\"LastCompletedCycleId\" IS NULL AND \"LastCompletedUpperOrdinal\" IS NULL AND \"LastCompletedAtUtc\" IS NULL AND \"LastCompletedScannedCount\" = 0) OR (\"LastCompletedCycleId\" IS NOT NULL AND (\"LastCompletedUpperOrdinal\" > 0 OR (\"LastCompletedUpperOrdinal\" IS NULL AND \"LastCompletedScannedCount\" = 0)) AND \"LastCompletedAtUtc\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_protocol", "\"ProtocolVersion\" = 1"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_run", "(\"CycleId\" IS NULL AND \"LastCompletedCycleId\" IS NULL AND \"LastAdvanceId\" IS NULL AND \"LastRunId\" IS NULL) OR (\"LastRunId\" IS NOT NULL AND (\"CycleId\" IS NOT NULL OR \"LastCompletedCycleId\" IS NOT NULL))"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_times", "(\"CycleStartedAtUtc\" IS NULL OR \"CycleStartedAtUtc\" <= \"UpdatedAtUtc\") AND (\"LastCompletedAtUtc\" IS NULL OR \"LastCompletedAtUtc\" <= \"UpdatedAtUtc\")"); + + t.HasCheckConstraint("CK_ws_anchor_sweep_version", "\"Version\" >= 1"); + }); + }); + modelBuilder.Entity("BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding", b => { b.Property("Id") @@ -948,6 +1245,36 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("IdentityAnchorContinuationEventId") + .HasColumnType("uuid"); + + b.Property("IdentityAnchorExpectedResolutionEventId") + .HasColumnType("uuid"); + + b.Property("IdentityAnchorResolutionApplicationVersion") + .HasColumnType("bigint"); + + b.Property("IdentityAnchorResolutionDisposition") + .HasColumnType("integer"); + + b.Property("IdentityAnchorResolutionEventId") + .HasColumnType("uuid"); + + b.Property("IdentityAnchorResolutionIntentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityAnchorResolutionObservedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityAnchorResolutionStaffMemberId") + .HasColumnType("uuid"); + + b.Property("IdentityAnchorSweepOrdinal") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("IdentityAnchorSweepOrdinal")); + b.Property("JobTitle") .HasMaxLength(128) .HasColumnType("character varying(128)"); @@ -1001,9 +1328,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasAlternateKey("ScopeId", "Id"); + b.HasIndex("IdentityAnchorContinuationEventId") + .IsUnique() + .HasFilter("\"IdentityAnchorContinuationEventId\" IS NOT NULL"); + + b.HasIndex("IdentityAnchorExpectedResolutionEventId") + .IsUnique() + .HasFilter("\"IdentityAnchorExpectedResolutionEventId\" IS NOT NULL"); + b.HasIndex("ScopeId", "ClaimId") .IsUnique(); + b.HasIndex("ScopeId", "IdentityAnchorSweepOrdinal") + .IsUnique(); + b.HasIndex("ScopeId", "StaffMemberId", "Id") .HasFilter("\"StaffMemberId\" IS NOT NULL"); @@ -1016,17 +1354,31 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("staff_onboarding_applications", "workspaces", t => { + t.HasCheckConstraint("CK_staff_onboarding_anchor_bound_redaction", "\"StaffMemberId\" IS NULL 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_anchor_expected_resolution", "(\"IdentityAnchorExpectedResolutionEventId\" IS NULL OR (\"StaffMemberId\" IS NOT NULL AND \"IdentityAnchorExpectedResolutionEventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"IdentityAnchorExpectedResolutionEventId\" <> \"Id\")) AND (\"IdentityAnchorContinuationEventId\" IS NULL OR (\"IdentityAnchorExpectedResolutionEventId\" IS NOT NULL AND \"IdentityAnchorContinuationEventId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"IdentityAnchorContinuationEventId\" <> \"Id\" AND \"IdentityAnchorContinuationEventId\" <> \"IdentityAnchorExpectedResolutionEventId\"))"); + + t.HasCheckConstraint("CK_staff_onboarding_anchor_resolution_coordinates", "\"IdentityAnchorResolutionEventId\" IS NULL OR (\"IdentityAnchorExpectedResolutionEventId\" IS NOT NULL AND \"IdentityAnchorResolutionEventId\" = \"IdentityAnchorExpectedResolutionEventId\" AND \"StaffMemberId\" IS NOT NULL AND \"IdentityAnchorResolutionStaffMemberId\" = \"StaffMemberId\")"); + + t.HasCheckConstraint("CK_staff_onboarding_anchor_resolution_intent", "(\"IdentityAnchorResolutionEventId\" IS NULL AND \"IdentityAnchorResolutionStaffMemberId\" IS NULL AND \"IdentityAnchorResolutionApplicationVersion\" IS NULL AND \"IdentityAnchorResolutionDisposition\" IS NULL AND \"IdentityAnchorResolutionIntentAtUtc\" IS NULL) OR (\"IdentityAnchorResolutionEventId\" IS NOT NULL AND \"StaffMemberId\" IS NOT NULL AND \"IdentityAnchorResolutionStaffMemberId\" IS NOT NULL AND \"IdentityAnchorResolutionApplicationVersion\" > 0 AND \"IdentityAnchorResolutionApplicationVersion\" <= \"Version\" AND \"IdentityAnchorResolutionDisposition\" BETWEEN 1 AND 5 AND \"IdentityAnchorResolutionIntentAtUtc\" IS NOT NULL AND \"IdentityAnchorResolutionIntentAtUtc\" <= \"LastChangedAtUtc\")"); + + t.HasCheckConstraint("CK_staff_onboarding_anchor_resolution_observation", "\"IdentityAnchorResolutionObservedAtUtc\" IS NULL OR (\"IdentityAnchorResolutionEventId\" IS NOT NULL AND \"IdentityAnchorResolutionObservedAtUtc\" >= \"IdentityAnchorResolutionIntentAtUtc\" AND \"IdentityAnchorResolutionObservedAtUtc\" <= \"LastChangedAtUtc\")"); + + t.HasCheckConstraint("CK_staff_onboarding_anchor_resolution_terminal", "\"IdentityAnchorResolutionEventId\" IS NULL OR ((\"Status\" = 5 AND \"IdentityAnchorResolutionDisposition\" = 1) OR (\"Status\" = 7 AND \"IdentityAnchorResolutionDisposition\" = 2) OR (\"Status\" = 8 AND \"IdentityAnchorResolutionDisposition\" = 3) OR (\"Status\" = 9 AND \"IdentityAnchorResolutionDisposition\" = 4) OR (\"Status\" = 10 AND \"IdentityAnchorResolutionDisposition\" = 5))"); + 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_identity_anchor_sweep_ordinal", "\"IdentityAnchorSweepOrdinal\" > 0"); + + t.HasCheckConstraint("CK_staff_onboarding_pending_profile", "\"StaffMemberId\" IS NOT NULL OR \"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_staff", "(\"StaffMemberId\" IS NULL OR \"StaffMemberId\" <> '00000000-0000-0000-0000-000000000000'::uuid) AND (\"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_terminal_redaction", "\"Status\" NOT IN (4, 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"); }); @@ -1161,7 +1513,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { 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_progress", "\"Stage\" BETWEEN 1 AND 22 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"); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffAccessProcessConfiguration.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffAccessProcessConfiguration.cs index 631fd01d..22131c47 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffAccessProcessConfiguration.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffAccessProcessConfiguration.cs @@ -13,6 +13,10 @@ public void Configure(EntityTypeBuilder builder) { table.HasCheckConstraint("CK_staff_access_process_version", "\"Version\" >= 1"); table.HasCheckConstraint("CK_staff_access_process_target", "\"TargetState\" BETWEEN 1 AND 3"); + table.HasCheckConstraint( + "CK_staff_access_process_restoration_disposition", + "(\"TargetState\" = 1 AND \"RestorationDisposition\" IN (2, 3)) OR " + + "(\"TargetState\" IN (2, 3) AND \"RestorationDisposition\" = 1)"); table.HasCheckConstraint("CK_staff_access_process_state", "\"State\" BETWEEN 1 AND 4"); table.HasCheckConstraint("CK_staff_access_process_staff_version", "\"TargetStaffVersion\" >= 2"); }); @@ -22,6 +26,9 @@ public void Configure(EntityTypeBuilder builder) builder.Property(process => process.SubjectId) .HasMaxLength(WorkspaceStaffAccessProcess.SubjectIdMaxLength).IsRequired(); builder.Property(process => process.TargetState).HasConversion().IsRequired(); + builder.Property(process => process.RestorationDisposition) + .HasConversion() + .IsRequired(); builder.Property(process => process.RequestedBy) .HasMaxLength(WorkspaceStaffAccessProcess.ActorIdMaxLength).IsRequired(); builder.Property(process => process.State).HasConversion().IsRequired(); 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/Configurations/WorkspaceStaffHistoricalNoProvisionReceiptConfiguration.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffHistoricalNoProvisionReceiptConfiguration.cs new file mode 100644 index 00000000..7c685c0c --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffHistoricalNoProvisionReceiptConfiguration.cs @@ -0,0 +1,133 @@ +namespace BunkFy.Modules.Workspaces.Persistence.Configurations; + +using BunkFy.Modules.Workspaces.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +internal sealed class WorkspaceStaffHistoricalNoProvisionReceiptConfiguration + : IEntityTypeConfiguration +{ + public void Configure( + EntityTypeBuilder builder) + { + builder.ToTable( + "staff_historical_no_provision_receipts", + table => + { + table.HasCheckConstraint( + "CK_ws_hist_no_prov_contract", + $"\"ContractVersion\" = " + + $"{WorkspaceStaffHistoricalNoProvisionReceipt.CurrentContractVersion}"); + table.HasCheckConstraint( + "CK_ws_hist_no_prov_versions", + "\"ExpectedApplicationVersion\" >= 1 AND " + + "\"ResultApplicationVersion\" >= " + + "\"ExpectedApplicationVersion\" AND " + + "\"ResultApplicationVersion\" <= " + + "\"ExpectedApplicationVersion\" + 1 AND " + + "\"OrganizationsScopeRevision\" >= 0 AND " + + "\"OrganizationsSourceVersion\" >= 1"); + table.HasCheckConstraint( + "CK_ws_hist_no_prov_transition", + "((\"ExpectedApplicationStatus\" IN (5, 7, 8, 9, 10) " + + "AND \"ResultApplicationStatus\" = " + + "\"ExpectedApplicationStatus\") OR " + + "(\"ExpectedApplicationStatus\" IN (1, 2, 3, 4, 6) " + + "AND \"ResultApplicationStatus\" = 8 AND " + + "\"ResultApplicationVersion\" = " + + "\"ExpectedApplicationVersion\" + 1))"); + table.HasCheckConstraint( + "CK_ws_hist_no_prov_authority", + "((\"SourceKind\" = 1 AND " + + "\"OrganizationsSourceStatus\" IN (3, 4, 5)) OR " + + "(\"SourceKind\" = 2 AND " + + "\"OrganizationsSourceStatus\" IN (7, 8, 9)))"); + table.HasCheckConstraint( + "CK_ws_hist_no_prov_hashes", + "\"StaffEvidenceSha256\" ~ '^[0-9a-f]{64}$' AND " + + "\"ExternalEvidenceSha256\" ~ '^[0-9a-f]{64}$' AND " + + "\"CanonicalSha256\" ~ '^[0-9a-f]{64}$'"); + table.HasCheckConstraint( + "CK_ws_hist_no_prov_identifiers", + "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid AND " + + "\"OperationId\" <> " + + "'00000000-0000-0000-0000-000000000000'::uuid AND " + + "\"ApplicationId\" <> " + + "'00000000-0000-0000-0000-000000000000'::uuid AND " + + "\"SourceId\" <> " + + "'00000000-0000-0000-0000-000000000000'::uuid AND " + + "\"ExternalEvidenceManifestId\" <> " + + "'00000000-0000-0000-0000-000000000000'::uuid"); + table.HasCheckConstraint( + "CK_ws_hist_no_prov_reviewer", + "char_length(\"ReviewerId\") BETWEEN 1 AND " + + $"{WorkspaceStaffHistoricalNoProvisionReceipt.ReviewerIdMaxLength} AND " + + "\"ReviewerId\" = btrim(\"ReviewerId\") AND " + + "\"ReviewerId\" !~ '[[:cntrl:]]'"); + }); + builder.HasKey(receipt => receipt.Id); + builder.HasAlternateKey(receipt => new + { + receipt.ScopeId, + receipt.Id + }); + builder.Property(receipt => receipt.ScopeId) + .HasMaxLength(128) + .IsRequired(); + builder.Property(receipt => receipt.ContractVersion).IsRequired(); + builder.Property(receipt => receipt.SourceKind) + .HasConversion() + .IsRequired(); + builder.Property(receipt => receipt.ExpectedApplicationStatus) + .HasConversion() + .IsRequired(); + builder.Property(receipt => receipt.ResultApplicationStatus) + .HasConversion() + .IsRequired(); + builder.Property(receipt => receipt.OrganizationsSourceStatus) + .HasConversion() + .IsRequired(); + builder.Property(receipt => receipt.StaffEvidenceSha256) + .HasMaxLength( + WorkspaceStaffHistoricalNoProvisionReceipt.Sha256Length) + .IsFixedLength() + .IsRequired(); + builder.Property(receipt => receipt.ExternalEvidenceSha256) + .HasMaxLength( + WorkspaceStaffHistoricalNoProvisionReceipt.Sha256Length) + .IsFixedLength() + .IsRequired(); + builder.Property(receipt => receipt.CanonicalSha256) + .HasMaxLength( + WorkspaceStaffHistoricalNoProvisionReceipt.Sha256Length) + .IsFixedLength() + .IsRequired(); + builder.Property(receipt => receipt.ReviewerId) + .HasMaxLength( + WorkspaceStaffHistoricalNoProvisionReceipt + .ReviewerIdMaxLength) + .IsRequired(); + builder.HasIndex(receipt => new + { + receipt.ScopeId, + receipt.OperationId + }).IsUnique(); + builder.HasIndex(receipt => new + { + receipt.ScopeId, + receipt.ApplicationId + }).IsUnique(); + builder.HasIndex(receipt => new + { + receipt.ScopeId, + receipt.SourceKind, + receipt.SourceId + }); + builder.HasIndex(receipt => new + { + receipt.ScopeId, + receipt.ExternalEvidenceManifestId + }); + builder.Ignore(receipt => receipt.DomainEvents); + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffIdentityAnchorSweepCheckpointConfiguration.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffIdentityAnchorSweepCheckpointConfiguration.cs new file mode 100644 index 00000000..90b12916 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffIdentityAnchorSweepCheckpointConfiguration.cs @@ -0,0 +1,163 @@ +namespace BunkFy.Modules.Workspaces.Persistence.Configurations; + +using BunkFy.Modules.Workspaces.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +internal sealed class + WorkspaceStaffIdentityAnchorSweepCheckpointConfiguration + : IEntityTypeConfiguration< + WorkspaceStaffIdentityAnchorSweepCheckpoint> +{ + public void Configure( + EntityTypeBuilder + builder) + { + builder.ToTable( + "workspace_staff_identity_anchor_sweep_checkpoints", + table => + { + table.HasCheckConstraint( + "CK_ws_anchor_sweep_protocol", + $"\"ProtocolVersion\" = " + + $"{WorkspaceStaffIdentityAnchorSweepCheckpoint.CurrentProtocolVersion}"); + table.HasCheckConstraint( + "CK_ws_anchor_sweep_identifiers", + "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid AND " + + "(\"CycleId\" IS NULL OR \"CycleId\" <> " + + "'00000000-0000-0000-0000-000000000000'::uuid) AND " + + "(\"LastCompletedCycleId\" IS NULL OR " + + "\"LastCompletedCycleId\" <> " + + "'00000000-0000-0000-0000-000000000000'::uuid) AND " + + "(\"LastAdvanceId\" IS NULL OR \"LastAdvanceId\" <> " + + "'00000000-0000-0000-0000-000000000000'::uuid) AND " + + "(\"LastRunId\" IS NULL OR \"LastRunId\" <> " + + "'00000000-0000-0000-0000-000000000000'::uuid)"); + table.HasCheckConstraint( + "CK_ws_anchor_sweep_version", + "\"Version\" >= 1"); + table.HasCheckConstraint( + "CK_ws_anchor_sweep_cycle", + "(\"CycleId\" IS NULL AND " + + "\"CycleUpperOrdinal\" IS NULL AND " + + "\"AfterOrdinal\" IS NULL AND " + + "\"CycleStartedAtUtc\" IS NULL AND " + + "\"CycleScannedCount\" = 0) OR " + + "(\"CycleId\" IS NOT NULL AND " + + "\"CycleUpperOrdinal\" > 0 AND " + + "(\"AfterOrdinal\" IS NULL OR " + + "(\"AfterOrdinal\" > 0 AND " + + "\"AfterOrdinal\" <= \"CycleUpperOrdinal\")) AND " + + "\"CycleStartedAtUtc\" IS NOT NULL)"); + table.HasCheckConstraint( + "CK_ws_anchor_sweep_cycle_counts_nonnegative", + "\"CycleScannedCount\" >= 0 AND " + + "\"CycleNoAnchorCount\" >= 0 AND " + + "\"CycleRemovedCount\" >= 0 AND " + + "\"CycleObservedCount\" >= 0 AND " + + "\"CycleAlreadyObservedCount\" >= 0 AND " + + "\"CycleDeferredCount\" >= 0 AND " + + "\"CycleConflictCount\" >= 0 AND " + + "\"CyclePassOneCommittedCount\" >= 0 AND " + + "\"CycleResolutionRecordConfirmedCount\" >= 0"); + table.HasCheckConstraint( + "CK_ws_anchor_sweep_cycle_counts_partition", + "\"CycleScannedCount\" = " + + "\"CycleNoAnchorCount\" + " + + "\"CycleRemovedCount\" + " + + "\"CycleObservedCount\" + " + + "\"CycleAlreadyObservedCount\" + " + + "\"CycleDeferredCount\" + " + + "\"CycleConflictCount\" AND " + + "\"CyclePassOneCommittedCount\" <= " + + "\"CycleScannedCount\" AND " + + "\"CycleResolutionRecordConfirmedCount\" <= " + + "\"CycleScannedCount\""); + table.HasCheckConstraint( + "CK_ws_anchor_sweep_last_counts_nonnegative", + "\"LastCompletedScannedCount\" >= 0 AND " + + "\"LastCompletedNoAnchorCount\" >= 0 AND " + + "\"LastCompletedRemovedCount\" >= 0 AND " + + "\"LastCompletedObservedCount\" >= 0 AND " + + "\"LastCompletedAlreadyObservedCount\" >= 0 AND " + + "\"LastCompletedDeferredCount\" >= 0 AND " + + "\"LastCompletedConflictCount\" >= 0 AND " + + "\"LastCompletedPassOneCommittedCount\" >= 0 AND " + + "\"LastCompletedResolutionRecordConfirmedCount\" >= 0"); + table.HasCheckConstraint( + "CK_ws_anchor_sweep_last_counts_partition", + "\"LastCompletedScannedCount\" = " + + "\"LastCompletedNoAnchorCount\" + " + + "\"LastCompletedRemovedCount\" + " + + "\"LastCompletedObservedCount\" + " + + "\"LastCompletedAlreadyObservedCount\" + " + + "\"LastCompletedDeferredCount\" + " + + "\"LastCompletedConflictCount\" AND " + + "\"LastCompletedPassOneCommittedCount\" <= " + + "\"LastCompletedScannedCount\" AND " + + "\"LastCompletedResolutionRecordConfirmedCount\" <= " + + "\"LastCompletedScannedCount\""); + table.HasCheckConstraint( + "CK_ws_anchor_sweep_last_cycle", + "(\"LastCompletedCycleId\" IS NULL AND " + + "\"LastCompletedUpperOrdinal\" IS NULL AND " + + "\"LastCompletedAtUtc\" IS NULL AND " + + "\"LastCompletedScannedCount\" = 0) OR " + + "(\"LastCompletedCycleId\" IS NOT NULL AND " + + "(\"LastCompletedUpperOrdinal\" > 0 OR " + + "(\"LastCompletedUpperOrdinal\" IS NULL AND " + + "\"LastCompletedScannedCount\" = 0)) AND " + + "\"LastCompletedAtUtc\" IS NOT NULL)"); + table.HasCheckConstraint( + "CK_ws_anchor_sweep_last_advance", + "(\"LastAdvanceId\" IS NULL AND " + + "\"LastAdvanceSha256\" IS NULL) OR " + + "(\"LastAdvanceId\" IS NOT NULL AND " + + "\"LastAdvanceSha256\" ~ '^[0-9a-f]{64}$')"); + table.HasCheckConstraint( + "CK_ws_anchor_sweep_run", + "(\"CycleId\" IS NULL AND " + + "\"LastCompletedCycleId\" IS NULL AND " + + "\"LastAdvanceId\" IS NULL AND \"LastRunId\" IS NULL) OR " + + "(\"LastRunId\" IS NOT NULL AND (" + + "\"CycleId\" IS NOT NULL OR " + + "\"LastCompletedCycleId\" IS NOT NULL))"); + table.HasCheckConstraint( + "CK_ws_anchor_sweep_times", + "(\"CycleStartedAtUtc\" IS NULL OR " + + "\"CycleStartedAtUtc\" <= \"UpdatedAtUtc\") AND " + + "(\"LastCompletedAtUtc\" IS NULL OR " + + "\"LastCompletedAtUtc\" <= \"UpdatedAtUtc\")"); + }); + builder.HasKey(checkpoint => checkpoint.Id); + builder.HasAlternateKey(checkpoint => new + { + checkpoint.ScopeId, + checkpoint.Id + }); + builder.Property(checkpoint => checkpoint.ScopeId) + .HasMaxLength(128) + .IsRequired(); + builder.Property(checkpoint => checkpoint.ProtocolVersion) + .IsRequired(); + builder.Property(checkpoint => checkpoint.LastAdvanceSha256) + .HasMaxLength( + WorkspaceStaffIdentityAnchorSweepCheckpoint + .AdvanceSha256Length); + builder.Property(checkpoint => checkpoint.Version) + .IsConcurrencyToken() + .IsRequired(); + builder.HasIndex(checkpoint => new + { + checkpoint.ScopeId, + checkpoint.ProtocolVersion + }).IsUnique(); + builder.HasIndex(checkpoint => new + { + checkpoint.ScopeId, + checkpoint.LastCompletedAtUtc + }); + builder.Ignore(checkpoint => checkpoint.DomainEvents); + builder.Ignore(checkpoint => checkpoint.HasActiveCycle); + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffOnboardingConfiguration.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffOnboardingConfiguration.cs index c20705ff..75231a47 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffOnboardingConfiguration.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Configurations/WorkspaceStaffOnboardingConfiguration.cs @@ -18,15 +18,80 @@ public void Configure(EntityTypeBuilder builder) "(\"ClaimId\" IS NULL AND \"ClaimVersion\" IS NULL) OR " + "(\"ClaimId\" IS NOT NULL AND \"ClaimVersion\" > 0)"); table.HasCheckConstraint("CK_staff_onboarding_staff", - "\"Status\" NOT IN (4, 5) OR \"StaffMemberId\" IS NOT NULL"); + "(\"StaffMemberId\" IS NULL OR \"StaffMemberId\" <> " + + "'00000000-0000-0000-0000-000000000000'::uuid) AND " + + "(\"Status\" NOT IN (4, 5) OR \"StaffMemberId\" IS NOT NULL)"); table.HasCheckConstraint("CK_staff_onboarding_pending_profile", + "\"StaffMemberId\" IS NOT NULL OR " + "\"Status\" IN (5, 7, 8, 9, 10) OR " + "(\"VerifiedAccountEmail\" IS NOT NULL AND \"DisplayName\" IS NOT NULL)"); table.HasCheckConstraint("CK_staff_onboarding_terminal_redaction", - "\"Status\" NOT IN (5, 7, 8, 9, 10) OR " + + "\"Status\" NOT IN (4, 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)"); + table.HasCheckConstraint("CK_staff_onboarding_anchor_bound_redaction", + "\"StaffMemberId\" IS NULL 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)"); + table.HasCheckConstraint( + "CK_staff_onboarding_anchor_expected_resolution", + "(\"IdentityAnchorExpectedResolutionEventId\" IS NULL OR " + + "(\"StaffMemberId\" IS NOT NULL AND " + + "\"IdentityAnchorExpectedResolutionEventId\" <> " + + "'00000000-0000-0000-0000-000000000000'::uuid AND " + + "\"IdentityAnchorExpectedResolutionEventId\" <> \"Id\")) AND " + + "(\"IdentityAnchorContinuationEventId\" IS NULL OR " + + "(\"IdentityAnchorExpectedResolutionEventId\" IS NOT NULL AND " + + "\"IdentityAnchorContinuationEventId\" <> " + + "'00000000-0000-0000-0000-000000000000'::uuid AND " + + "\"IdentityAnchorContinuationEventId\" <> \"Id\" AND " + + "\"IdentityAnchorContinuationEventId\" <> " + + "\"IdentityAnchorExpectedResolutionEventId\"))"); + table.HasCheckConstraint( + "CK_staff_onboarding_anchor_resolution_intent", + "(\"IdentityAnchorResolutionEventId\" IS NULL AND " + + "\"IdentityAnchorResolutionStaffMemberId\" IS NULL AND " + + "\"IdentityAnchorResolutionApplicationVersion\" IS NULL AND " + + "\"IdentityAnchorResolutionDisposition\" IS NULL AND " + + "\"IdentityAnchorResolutionIntentAtUtc\" IS NULL) OR " + + "(\"IdentityAnchorResolutionEventId\" IS NOT NULL AND " + + "\"StaffMemberId\" IS NOT NULL AND " + + "\"IdentityAnchorResolutionStaffMemberId\" IS NOT NULL AND " + + "\"IdentityAnchorResolutionApplicationVersion\" > 0 AND " + + "\"IdentityAnchorResolutionApplicationVersion\" <= \"Version\" AND " + + "\"IdentityAnchorResolutionDisposition\" BETWEEN 1 AND 5 AND " + + "\"IdentityAnchorResolutionIntentAtUtc\" IS NOT NULL AND " + + "\"IdentityAnchorResolutionIntentAtUtc\" <= " + + "\"LastChangedAtUtc\")"); + table.HasCheckConstraint( + "CK_staff_onboarding_anchor_resolution_coordinates", + "\"IdentityAnchorResolutionEventId\" IS NULL OR " + + "(\"IdentityAnchorExpectedResolutionEventId\" IS NOT NULL AND " + + "\"IdentityAnchorResolutionEventId\" = " + + "\"IdentityAnchorExpectedResolutionEventId\" AND " + + "\"StaffMemberId\" IS NOT NULL AND " + + "\"IdentityAnchorResolutionStaffMemberId\" = \"StaffMemberId\")"); + table.HasCheckConstraint( + "CK_staff_onboarding_anchor_resolution_terminal", + "\"IdentityAnchorResolutionEventId\" IS NULL OR " + + "((\"Status\" = 5 AND \"IdentityAnchorResolutionDisposition\" = 1) OR " + + "(\"Status\" = 7 AND \"IdentityAnchorResolutionDisposition\" = 2) OR " + + "(\"Status\" = 8 AND \"IdentityAnchorResolutionDisposition\" = 3) OR " + + "(\"Status\" = 9 AND \"IdentityAnchorResolutionDisposition\" = 4) OR " + + "(\"Status\" = 10 AND \"IdentityAnchorResolutionDisposition\" = 5))"); + table.HasCheckConstraint( + "CK_staff_onboarding_anchor_resolution_observation", + "\"IdentityAnchorResolutionObservedAtUtc\" IS NULL OR " + + "(\"IdentityAnchorResolutionEventId\" IS NOT NULL AND " + + "\"IdentityAnchorResolutionObservedAtUtc\" >= " + + "\"IdentityAnchorResolutionIntentAtUtc\" AND " + + "\"IdentityAnchorResolutionObservedAtUtc\" <= " + + "\"LastChangedAtUtc\")"); + table.HasCheckConstraint( + "CK_staff_onboarding_identity_anchor_sweep_ordinal", + "\"IdentityAnchorSweepOrdinal\" > 0"); }); builder.HasKey(application => application.Id); builder.HasAlternateKey(application => new { application.ScopeId, application.Id }); @@ -51,8 +116,15 @@ public void Configure(EntityTypeBuilder builder) builder.Property(application => application.Department) .HasMaxLength(WorkspaceStaffOnboardingRules.DepartmentMaxLength); builder.Property(application => application.Status).HasConversion().IsRequired(); + builder.Property(application => application.IdentityAnchorSweepOrdinal) + .UseIdentityAlwaysColumn() + .ValueGeneratedOnAdd() + .IsRequired(); builder.Property(application => application.FailureCode) .HasMaxLength(WorkspaceStaffOnboardingRules.FailureCodeMaxLength); + builder.Property(application => + application.IdentityAnchorResolutionDisposition) + .HasConversion(); builder.Property(application => application.Version).IsConcurrencyToken().IsRequired(); builder.HasIndex(application => new { @@ -86,6 +158,21 @@ public void Configure(EntityTypeBuilder builder) application.StaffMemberId, application.Id }).HasFilter("\"StaffMemberId\" IS NOT NULL"); + builder.HasIndex(application => new + { + application.ScopeId, + application.IdentityAnchorSweepOrdinal + }).IsUnique(); + builder.HasIndex(application => + application.IdentityAnchorExpectedResolutionEventId) + .IsUnique() + .HasFilter( + "\"IdentityAnchorExpectedResolutionEventId\" IS NOT NULL"); + builder.HasIndex(application => + application.IdentityAnchorContinuationEventId) + .IsUnique() + .HasFilter( + "\"IdentityAnchorContinuationEventId\" IS NOT NULL"); builder.Ignore(application => application.IsAdmissible); builder.Ignore(application => application.HasApplicantAuthority); builder.Ignore(application => application.DomainEvents); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/DependencyInjection.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/DependencyInjection.cs index f1429d8d..0ad71c42 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/DependencyInjection.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/DependencyInjection.cs @@ -33,6 +33,27 @@ public static IHostApplicationBuilder AddWorkspacesPersistence( builder.Services.TryAddScoped< IWorkspaceStaffOnboardingRepository, WorkspaceStaffOnboardingRepository>(); + builder.Services.TryAddScoped< + IWorkspaceStaffOnboardingRestorationSuppressionReader, + WorkspaceStaffOnboardingRestorationSuppressionReader>(); + builder.Services.TryAddScoped< + IWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence, + WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence>(); + builder.Services.TryAddScoped< + IWorkspaceStaffIdentityAnchorCutoverSourceReader, + WorkspaceStaffIdentityAnchorCutoverSourceReader>(); + builder.Services.TryAddScoped< + IWorkspaceStaffIdentityAnchorSweepRepository, + WorkspaceStaffIdentityAnchorSweepRepository>(); + builder.Services.TryAddScoped< + IWorkspaceStaffHistoricalNoProvisionReceiptRepository, + WorkspaceStaffHistoricalNoProvisionReceiptRepository>(); + builder.Services.TryAddScoped< + IWorkspaceIdentityAnchorCutoverExecutionBoundary, + WorkspaceIdentityAnchorCutoverExecutionBoundary>(); + builder.Services.TryAddScoped< + IWorkspaceStaffDeferredClaimWithdrawalRepository, + WorkspaceStaffDeferredClaimWithdrawalRepository>(); builder.Services.TryAddScoped< IWorkspaceStaffOnboardingRetentionRepository, WorkspaceStaffOnboardingRetentionRepository>(); @@ -48,6 +69,9 @@ public static IHostApplicationBuilder AddWorkspacesPersistence( builder.Services.TryAddScoped< IWorkspaceStaffOnboardingOperationLock, WorkspaceStaffOnboardingOperationLock>(); + builder.Services.TryAddScoped< + IWorkspaceStaffOnboardingSerializedReadBoundary, + WorkspaceStaffOnboardingSerializedReadBoundary>(); builder.Services.TryAddScoped< IWorkspaceStaffOnboardingDataRightsCorrectionTargetReader, WorkspaceStaffOnboardingDataRightsCorrectionTargetReader>(); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceIdentityAnchorCutoverExecutionBoundary.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceIdentityAnchorCutoverExecutionBoundary.cs new file mode 100644 index 00000000..c2b929cb --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceIdentityAnchorCutoverExecutionBoundary.cs @@ -0,0 +1,64 @@ +namespace BunkFy.Modules.Workspaces.Persistence.Repositories; + +using BunkFy.Modules.Workspaces.Application.Ports; +using Gma.Framework.Results; +using Microsoft.EntityFrameworkCore.Storage; + +internal sealed class WorkspaceIdentityAnchorCutoverExecutionBoundary( + WorkspacesDbContext dbContext) + : IWorkspaceIdentityAnchorCutoverExecutionBoundary +{ + public async Task> ExecuteAsync( + Func>> operation, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(operation); + + if (dbContext.Database.CurrentTransaction is not null) + { + throw new InvalidOperationException( + "The identity-anchor cutover boundary must own its transaction."); + } + + IDbContextTransaction transaction = await dbContext + .Database.BeginTransactionAsync(cancellationToken) + .ConfigureAwait(false); + Result? operationResult = null; + bool rollbackCompleted = false; + try + { + operationResult = await operation(cancellationToken) + .ConfigureAwait(false) ?? throw new InvalidOperationException( + "The identity-anchor cutover read boundary received no operation result."); + await transaction.RollbackAsync(CancellationToken.None) + .ConfigureAwait(false); + rollbackCompleted = true; + } + catch + { + if (!rollbackCompleted) + { + await transaction.RollbackAsync(CancellationToken.None) + .ConfigureAwait(false); + rollbackCompleted = true; + } + + throw; + } + finally + { + try + { + await transaction.DisposeAsync().ConfigureAwait(false); + } + catch when (rollbackCompleted && operationResult is not null) + { + // Rollback conclusively released the transaction-scoped tenant + // lock. A later disposal failure cannot make Staff apply + // transactional with this read boundary. + } + } + + return operationResult; + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffAccessProcessRepository.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffAccessProcessRepository.cs index 28906058..caf99e8e 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffAccessProcessRepository.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffAccessProcessRepository.cs @@ -81,6 +81,7 @@ public async Task ListOpenAsync( process.ScopeId, process.StaffMemberId, process.TargetState, + process.RestorationDisposition, process.TargetStaffVersion, process.EffectiveOn, process.State, @@ -99,6 +100,8 @@ public async Task ListOpenAsync( Guid.Parse(process.ScopeId), process.StaffMemberId, WorkspaceStaffAccessMappings.MapTargetStatus(process.TargetState), + WorkspaceStaffAccessMappings.MapRestorationDisposition( + process.RestorationDisposition), process.TargetStaffVersion, process.EffectiveOn, WorkspaceStaffAccessMappings.MapStatus(process.State), 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/WorkspaceStaffHistoricalNoProvisionReceiptQuery.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffHistoricalNoProvisionReceiptQuery.cs new file mode 100644 index 00000000..0368438f --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffHistoricalNoProvisionReceiptQuery.cs @@ -0,0 +1,52 @@ +namespace BunkFy.Modules.Workspaces.Persistence.Repositories; + +using BunkFy.Modules.Workspaces.Domain; + +internal static class WorkspaceStaffHistoricalNoProvisionReceiptQuery +{ + public static IQueryable ExcludeExactlyReviewed( + this IQueryable applications, + WorkspacesDbContext dbContext) => + applications.Where(application => + !dbContext.StaffHistoricalNoProvisionReceipts.Any(receipt => + receipt.ScopeId == application.ScopeId && + receipt.ContractVersion == + WorkspaceStaffHistoricalNoProvisionReceipt + .CurrentContractVersion && + receipt.ApplicationId == application.Id && + receipt.SourceKind == application.SourceKind && + receipt.SourceId == application.SourceId && + receipt.ResultApplicationVersion == application.Version && + receipt.ResultApplicationStatus == application.Status && + (receipt.ResultApplicationStatus == + WorkspaceStaffOnboardingState.Completed || + receipt.ResultApplicationStatus == + WorkspaceStaffOnboardingState.Rejected || + receipt.ResultApplicationStatus == + WorkspaceStaffOnboardingState.Superseded || + receipt.ResultApplicationStatus == + WorkspaceStaffOnboardingState.Expired || + receipt.ResultApplicationStatus == + WorkspaceStaffOnboardingState.Withdrawn) && + application.SubjectId == + WorkspaceStaffHistoricalNoProvisionReceipt + .SubjectPseudonymPrefix + receipt.Id.ToString() && + application.VerifiedAccountEmail == null && + application.DisplayName == null && + application.LegalName == null && + application.WorkEmail == null && + application.WorkPhone == null && + application.EmployeeNumber == null && + application.JobTitle == null && + application.Department == null && + application.FailureCode == null && + application.StaffMemberId == null && + application.IdentityAnchorExpectedResolutionEventId == null && + application.IdentityAnchorContinuationEventId == null && + application.IdentityAnchorResolutionEventId == null && + application.IdentityAnchorResolutionStaffMemberId == null && + application.IdentityAnchorResolutionApplicationVersion == null && + application.IdentityAnchorResolutionDisposition == null && + application.IdentityAnchorResolutionIntentAtUtc == null && + application.IdentityAnchorResolutionObservedAtUtc == null)); +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffHistoricalNoProvisionReceiptRepository.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffHistoricalNoProvisionReceiptRepository.cs new file mode 100644 index 00000000..becdf10a --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffHistoricalNoProvisionReceiptRepository.cs @@ -0,0 +1,37 @@ +namespace BunkFy.Modules.Workspaces.Persistence.Repositories; + +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using Microsoft.EntityFrameworkCore; + +internal sealed class WorkspaceStaffHistoricalNoProvisionReceiptRepository( + WorkspacesDbContext dbContext) + : IWorkspaceStaffHistoricalNoProvisionReceiptRepository +{ + public Task + FindByOperationIdAsync( + Guid operationId, + CancellationToken cancellationToken) => + dbContext.StaffHistoricalNoProvisionReceipts + .SingleOrDefaultAsync( + receipt => receipt.OperationId == operationId, + cancellationToken); + + public Task + FindByApplicationIdAsync( + Guid applicationId, + CancellationToken cancellationToken) => + dbContext.StaffHistoricalNoProvisionReceipts + .SingleOrDefaultAsync( + receipt => receipt.ApplicationId == applicationId, + cancellationToken); + + public Task AddAsync( + WorkspaceStaffHistoricalNoProvisionReceipt receipt, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(receipt); + dbContext.StaffHistoricalNoProvisionReceipts.Add(receipt); + return Task.CompletedTask; + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffIdentityAnchorCutoverSourceReader.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffIdentityAnchorCutoverSourceReader.cs new file mode 100644 index 00000000..9aeeee7d --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffIdentityAnchorCutoverSourceReader.cs @@ -0,0 +1,66 @@ +namespace BunkFy.Modules.Workspaces.Persistence.Repositories; + +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using Microsoft.EntityFrameworkCore; + +internal sealed class WorkspaceStaffIdentityAnchorCutoverSourceReader( + WorkspacesDbContext dbContext) + : IWorkspaceStaffIdentityAnchorCutoverSourceReader +{ + public async Task + ListRelevantPageAsync( + Guid? afterApplicationId, + int pageSize, + CancellationToken cancellationToken) + { + if (afterApplicationId == Guid.Empty) + { + throw new ArgumentException( + "The Workspaces identity-anchor source cursor is invalid.", + nameof(afterApplicationId)); + } + + if (pageSize is < 1 or > + WorkspaceStaffIdentityAnchorCutoverSourceLimits.PageSize) + { + throw new ArgumentOutOfRangeException( + nameof(pageSize), + pageSize, + "The Workspaces identity-anchor source page size is invalid."); + } + + IQueryable query = dbContext + .StaffOnboardingApplications + .AsNoTracking() + .ExcludeExactlyReviewed(dbContext); + if (afterApplicationId.HasValue) + { + Guid cursor = afterApplicationId.Value; + query = query.Where(application => + application.Id.CompareTo(cursor) > 0); + } + + WorkspaceStaffIdentityAnchorSourceRecord[] loaded = await query + .OrderBy(application => application.Id) + .Select(application => + new WorkspaceStaffIdentityAnchorSourceRecord( + application.Id, + application.StaffMemberId, + application.SubjectId, + application.Status)) + .Take(pageSize + 1) + .ToArrayAsync(cancellationToken).ConfigureAwait(false); + bool hasMore = loaded.Length > pageSize; + WorkspaceStaffIdentityAnchorSourceRecord[] selected = loaded + .Take(pageSize) + .ToArray(); + Guid? nextApplicationId = selected.Length == 0 + ? afterApplicationId + : selected[^1].ApplicationId; + return new( + Array.AsReadOnly(selected), + nextApplicationId, + hasMore); + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffIdentityAnchorSweepRepository.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffIdentityAnchorSweepRepository.cs new file mode 100644 index 00000000..7ba865ab --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffIdentityAnchorSweepRepository.cs @@ -0,0 +1,279 @@ +namespace BunkFy.Modules.Workspaces.Persistence.Repositories; + +using System.Runtime.CompilerServices; +using BunkFy.Modules.Workspaces.Application.Models; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Domain.Termination; +using Gma.Framework.Results; +using Microsoft.EntityFrameworkCore; + +internal sealed class WorkspaceStaffIdentityAnchorSweepRepository( + WorkspacesDbContext dbContext) + : IWorkspaceStaffIdentityAnchorSweepRepository +{ + public async Task> + PreparePageAsync( + string scopeId, + Guid checkpointId, + Guid cycleId, + Guid emptyAdvanceId, + Guid runId, + int batchSize, + DateTimeOffset nowUtc, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(scopeId) || + checkpointId == Guid.Empty || + cycleId == Guid.Empty || + emptyAdvanceId == Guid.Empty || + runId == Guid.Empty || + batchSize is <= 0 or > 500 || + nowUtc == default) + { + return Result.Failure( + WorkspaceStaffIdentityAnchorSweepErrors.Invalid); + } + + DbSet checkpoints = + dbContext.Set(); + WorkspaceStaffIdentityAnchorSweepCheckpoint? checkpoint = + await checkpoints.SingleOrDefaultAsync( + candidate => candidate.ScopeId == scopeId && + candidate.ProtocolVersion == + WorkspaceStaffIdentityAnchorSweepCheckpoint + .CurrentProtocolVersion, + cancellationToken) + .ConfigureAwait(false); + if (checkpoint is null) + { + Result created = + WorkspaceStaffIdentityAnchorSweepCheckpoint.Create( + checkpointId, + scopeId, + nowUtc); + if (created.IsFailure) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorSweepPage>(created.Error); + } + + checkpoint = created.Value; + checkpoints.Add(checkpoint); + } + + if (!checkpoint.HasActiveCycle) + { + long? upper = await dbContext.StaffOnboardingApplications + .AsNoTracking() + .Where(application => application.ScopeId == scopeId) + .OrderByDescending(application => + application.IdentityAnchorSweepOrdinal) + .Select(application => + (long?)application.IdentityAnchorSweepOrdinal) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + if (!upper.HasValue) + { + Result completed = checkpoint.CompleteEmptyCycle( + cycleId, + emptyAdvanceId, + runId, + nowUtc); + return completed.IsSuccess + ? Result.Success( + new WorkspaceStaffIdentityAnchorSweepPage( + checkpoint.Id, + checkpoint.Version, + cycleId, + null, + null, + null, + ReachedEnd: true, + AdvanceRequired: false, + [])) + : Result.Failure< + WorkspaceStaffIdentityAnchorSweepPage>( + completed.Error); + } + + Result begun = checkpoint.BeginCycle( + cycleId, + upper.Value, + runId, + nowUtc); + if (begun.IsFailure) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorSweepPage>(begun.Error); + } + } + + Guid expectedCycleId = checkpoint.CycleId!.Value; + long upperOrdinal = checkpoint.CycleUpperOrdinal!.Value; + long? expectedAfterOrdinal = checkpoint.AfterOrdinal; + IQueryable query = dbContext + .StaffOnboardingApplications + .AsNoTracking() + .ExcludeExactlyReviewed(dbContext) + .Where(application => + application.ScopeId == scopeId && + application.IdentityAnchorSweepOrdinal <= upperOrdinal); + if (expectedAfterOrdinal.HasValue) + { + long after = expectedAfterOrdinal.Value; + query = query.Where(application => + application.IdentityAnchorSweepOrdinal > after); + } + + WorkspaceStaffIdentityAnchorSweepCandidate[] loaded = await query + .OrderBy(application => application.IdentityAnchorSweepOrdinal) + .Select(application => + new WorkspaceStaffIdentityAnchorSweepCandidate( + application.Id, + application.IdentityAnchorSweepOrdinal, + application.SubjectId, + application.StaffMemberId != null || + application.IdentityAnchorExpectedResolutionEventId != + null || + application.IdentityAnchorContinuationEventId != null || + application.IdentityAnchorResolutionEventId != null || + application.IdentityAnchorResolutionStaffMemberId != null || + application.IdentityAnchorResolutionApplicationVersion != + null || + application.IdentityAnchorResolutionDisposition != null || + application.IdentityAnchorResolutionIntentAtUtc != null || + application.IdentityAnchorResolutionObservedAtUtc != null)) + .Take(batchSize + 1) + .ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + bool reachedEnd = loaded.Length <= batchSize; + WorkspaceStaffIdentityAnchorSweepCandidate[] selected = loaded + .Take(batchSize) + .ToArray(); + long nextAfterOrdinal = selected.Length == 0 + ? upperOrdinal + : selected[^1].IdentityAnchorSweepOrdinal; + return Result.Success( + new WorkspaceStaffIdentityAnchorSweepPage( + checkpoint.Id, + checkpoint.Version, + expectedCycleId, + upperOrdinal, + expectedAfterOrdinal, + nextAfterOrdinal, + reachedEnd, + AdvanceRequired: true, + Array.AsReadOnly(selected))); + } + + public async Task AdvanceAsync( + WorkspaceStaffIdentityAnchorSweepAdvance advance, + DateTimeOffset nowUtc, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(advance); + WorkspaceStaffIdentityAnchorSweepCheckpoint? checkpoint = + await dbContext + .Set() + .SingleOrDefaultAsync( + candidate => candidate.Id == advance.CheckpointId, + cancellationToken) + .ConfigureAwait(false); + return checkpoint is null + ? Result.Failure( + WorkspaceStaffIdentityAnchorSweepErrors.CheckpointConflict) + : checkpoint.Advance( + advance.ExpectedCheckpointVersion, + advance.ExpectedCycleId, + advance.ExpectedAfterOrdinal, + advance.NextAfterOrdinal, + advance.ReachedEnd, + advance.AdvanceId, + advance.RunId, + advance.Counts, + nowUtc); + } + + public async Task GetStatusAsync( + string scopeId, + CancellationToken cancellationToken) + { + WorkspaceStaffIdentityAnchorSweepCheckpoint? checkpoint = + await dbContext + .Set() + .AsNoTracking() + .SingleOrDefaultAsync( + candidate => candidate.ScopeId == scopeId && + candidate.ProtocolVersion == + WorkspaceStaffIdentityAnchorSweepCheckpoint + .CurrentProtocolVersion, + cancellationToken) + .ConfigureAwait(false); + return checkpoint is null + ? new WorkspaceStaffIdentityAnchorSweepStatus( + scopeId, + HasCheckpoint: false, + WorkspaceStaffIdentityAnchorSweepCheckpoint + .CurrentProtocolVersion, + CheckpointVersion: 0, + HasActiveCycle: false, + CycleId: null, + CycleUpperOrdinal: null, + AfterOrdinal: null, + CycleStartedAtUtc: null, + WorkspaceStaffIdentityAnchorSweepPageCounts.Empty, + LastCompletedCycleId: null, + LastCompletedUpperOrdinal: null, + LastCompletedAtUtc: null, + WorkspaceStaffIdentityAnchorSweepPageCounts.Empty, + LastRunId: null, + UpdatedAtUtc: null, + HasCompletedBoundedCycle: false) + : new WorkspaceStaffIdentityAnchorSweepStatus( + checkpoint.ScopeId, + HasCheckpoint: true, + checkpoint.ProtocolVersion, + checkpoint.Version, + checkpoint.HasActiveCycle, + checkpoint.CycleId, + checkpoint.CycleUpperOrdinal, + checkpoint.AfterOrdinal, + checkpoint.CycleStartedAtUtc, + checkpoint.CurrentCounts(), + checkpoint.LastCompletedCycleId, + checkpoint.LastCompletedUpperOrdinal, + checkpoint.LastCompletedAtUtc, + checkpoint.LastCompletedCounts(), + checkpoint.LastRunId, + checkpoint.UpdatedAtUtc, + HasCompletedBoundedCycle: + checkpoint.LastCompletedCycleId.HasValue && + checkpoint.LastCompletedAtUtc.HasValue); + } + + public async IAsyncEnumerable StreamScheduleScopeIdsAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + IQueryable fences = dbContext + .WorkspaceTerminationFences + .IgnoreQueryFilters() + .AsNoTracking(); + IQueryable scopes = dbContext.StaffOnboardingApplications + .IgnoreQueryFilters() + .AsNoTracking() + .Where(application => !fences.Any(fence => + fence.ScopeId == application.ScopeId && + fence.State != WorkspaceTerminationFenceState.Released)) + .Select(application => application.ScopeId) + .Distinct() + .Order(); + await foreach (string scopeId in scopes + .AsAsyncEnumerable() + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + yield return scopeId; + } + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingExportAuthority.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingExportAuthority.cs new file mode 100644 index 00000000..d53eba3a --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingExportAuthority.cs @@ -0,0 +1,178 @@ +namespace BunkFy.Modules.Workspaces.Persistence.Repositories; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Domain; + +internal static class WorkspaceStaffOnboardingExportAuthority +{ + public static bool IsAuthorized( + WorkspaceStaffOnboarding application, + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome) + { + ArgumentNullException.ThrowIfNull(application); + ArgumentNullException.ThrowIfNull(outcome); + return IsExactAbsent(application, outcome) || + IsExactObservedResolution(application, outcome); + } + + private static bool IsExactAbsent( + WorkspaceStaffOnboarding application, + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome) => + outcome.ApplicationId == application.Id && + outcome.Status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Absent && + !outcome.StaffMemberId.HasValue && + outcome.TargetLifecycle == + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Unknown && + outcome.SubjectMatch == + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Unknown && + !outcome.WorkspaceApplicationVersion.HasValue && + !outcome.ResolutionDisposition.HasValue && + !outcome.ResolutionEventId.HasValue && + !HasLocalAnchorCoordinates(application); + + private static bool IsExactObservedResolution( + WorkspaceStaffOnboarding application, + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome) + { + if (outcome.ApplicationId != application.Id || + outcome.Status != + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved || + !outcome.StaffMemberId.HasValue || + outcome.StaffMemberId.Value == Guid.Empty || + !outcome.WorkspaceApplicationVersion.HasValue || + outcome.WorkspaceApplicationVersion.Value <= 0 || + !outcome.ResolutionDisposition.HasValue || + !outcome.ResolutionEventId.HasValue || + outcome.ResolutionEventId.Value == Guid.Empty || + outcome.ResolutionEventId.Value == application.Id || + !HasExactSubjectRelationship(outcome) || + !TryMapDisposition( + outcome.ResolutionDisposition.Value, + out WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + disposition) || + application.StaffMemberId != outcome.StaffMemberId || + application.IdentityAnchorExpectedResolutionEventId != + outcome.ResolutionEventId || + application.IdentityAnchorResolutionEventId != + outcome.ResolutionEventId || + application.IdentityAnchorResolutionStaffMemberId != + outcome.StaffMemberId || + application.IdentityAnchorResolutionApplicationVersion != + outcome.WorkspaceApplicationVersion || + application.IdentityAnchorResolutionDisposition != disposition || + !application.IdentityAnchorResolutionIntentAtUtc.HasValue || + !application.IdentityAnchorResolutionObservedAtUtc.HasValue || + application.IdentityAnchorResolutionObservedAtUtc.Value < + application.IdentityAnchorResolutionIntentAtUtc.Value || + application.Version < outcome.WorkspaceApplicationVersion.Value || + !StatusMatchesDisposition(application.Status, disposition) || + !IsApplicantProfileRedacted(application)) + { + return false; + } + + return !application.IdentityAnchorContinuationEventId.HasValue || + (application.IdentityAnchorContinuationEventId.Value != Guid.Empty && + application.IdentityAnchorContinuationEventId.Value != + application.Id && + application.IdentityAnchorContinuationEventId.Value != + outcome.ResolutionEventId.Value); + } + + private static bool HasLocalAnchorCoordinates( + WorkspaceStaffOnboarding application) => + application.StaffMemberId.HasValue || + application.IdentityAnchorExpectedResolutionEventId.HasValue || + application.IdentityAnchorContinuationEventId.HasValue || + application.IdentityAnchorResolutionEventId.HasValue || + application.IdentityAnchorResolutionStaffMemberId.HasValue || + application.IdentityAnchorResolutionApplicationVersion.HasValue || + application.IdentityAnchorResolutionDisposition.HasValue || + application.IdentityAnchorResolutionIntentAtUtc.HasValue || + application.IdentityAnchorResolutionObservedAtUtc.HasValue; + + private static bool HasExactSubjectRelationship( + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome) => + outcome.TargetLifecycle switch + { + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active or + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Suspended or + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Departed => + outcome.SubjectMatch is + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact or + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Mismatch, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Anonymised => + outcome.SubjectMatch == + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Missing, + _ => false + }; + + private static bool IsApplicantProfileRedacted( + WorkspaceStaffOnboarding application) => + application.VerifiedAccountEmail is null && + application.DisplayName is null && + application.LegalName is null && + application.WorkEmail is null && + application.WorkPhone is null && + application.EmployeeNumber is null && + application.JobTitle is null && + application.Department is null; + + private static bool StatusMatchesDisposition( + WorkspaceStaffOnboardingState status, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + disposition) => + (status, disposition) switch + { + (WorkspaceStaffOnboardingState.Completed, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted) => true, + (WorkspaceStaffOnboardingState.Rejected, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted) => true, + (WorkspaceStaffOnboardingState.Superseded, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted) => true, + (WorkspaceStaffOnboardingState.Expired, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted) => true, + (WorkspaceStaffOnboardingState.Withdrawn, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted) => true, + _ => false + }; + + private static bool TryMapDisposition( + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition source, + out WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition target) + { + target = source switch + { + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted => + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted, + _ => WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .Unknown + }; + return target != + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition.Unknown; + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence.cs new file mode 100644 index 00000000..a0fdf81d --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence.cs @@ -0,0 +1,149 @@ +namespace BunkFy.Modules.Workspaces.Persistence.Repositories; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Naming; +using Gma.Framework.Scoping; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +internal sealed class + WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence( + WorkspacesDbContext dbContext, + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader outcomes, + IScopeContext scopeContext, + ILogger< + WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence> + logger) + : IWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence +{ + private const int PageSize = + StaffWorkspaceOnboardingIdentityAnchorLifecycleLimits.MaximumBatchSize; + + public async Task CanMutateAsync( + string tenantId, + string? subjectId, + CancellationToken cancellationToken) + { + string normalizedSubject = subjectId?.Trim() ?? string.Empty; + if (!TenantIds.TryNormalize(tenantId, out string? normalizedTenant)) + { + return false; + } + + if (!scopeContext.IsEnabled || + !TenantIds.TryNormalize( + scopeContext.ScopeId, + out string? activeTenant) || + !string.Equals( + normalizedTenant, + activeTenant, + StringComparison.Ordinal)) + { + return false; + } + + if (normalizedSubject.Length == 0) + { + return true; + } + + if (normalizedSubject.Length > + WorkspaceStaffOnboardingRules.SubjectIdMaxLength) + { + return false; + } + + Guid? afterApplicationId = null; + while (true) + { + IQueryable query = dbContext + .StaffOnboardingApplications + .AsNoTracking() + .Where(application => + application.ScopeId == normalizedTenant && + application.SubjectId == normalizedSubject); + if (afterApplicationId.HasValue) + { + Guid cursor = afterApplicationId.Value; + query = query.Where(application => + application.Id.CompareTo(cursor) > 0); + } + + WorkspaceStaffOnboarding[] page = await query + .OrderBy(application => application.Id) + .Take(PageSize) + .ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + if (page.Length == 0) + { + return true; + } + + IReadOnlyList read; + try + { + read = await outcomes.ReadAsync( + page.Select(application => + new + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest( + application.Id, + application.SubjectId)) + .ToArray(), + cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + when (!cancellationToken.IsCancellationRequested) + { + logger.LogWarning( + "Staff identity-anchor subject-mutation preflight was cancelled outside the Workspaces request."); + return false; + } + catch (Exception exception) when (exception is not + OperationCanceledException) + { + logger.LogWarning( + "Staff identity-anchor subject-mutation preflight is unavailable for the Workspaces request."); + return false; + } + + if (read.Count != page.Length || + read.Select(outcome => outcome.ApplicationId) + .Distinct().Count() != read.Count) + { + return false; + } + + Dictionary + byApplication = read.ToDictionary( + outcome => outcome.ApplicationId); + if (page.Any(application => + !byApplication.TryGetValue( + application.Id, + out StaffWorkspaceOnboardingIdentityAnchorOutcome? + outcome) || + !WorkspaceStaffOnboardingExportAuthority.IsAuthorized( + application, + outcome))) + { + return false; + } + + Guid next = page[^1].Id; + if (afterApplicationId.HasValue && + next.CompareTo(afterApplicationId.Value) <= 0) + { + return false; + } + + if (page.Length < PageSize) + { + return true; + } + + afterApplicationId = next; + } + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingRepository.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingRepository.cs index 9d218066..48a316ab 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingRepository.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingRepository.cs @@ -35,6 +35,7 @@ internal sealed class WorkspaceStaffOnboardingRepository(WorkspacesDbContext dbC CancellationToken cancellationToken) => ( from application in dbContext.StaffOnboardingApplications + .ExcludeExactlyReviewed(dbContext) join projection in dbContext.StaffOnboardingProcessingRestrictionProjections on new @@ -66,6 +67,7 @@ public Task return ( from application in dbContext.StaffOnboardingApplications .AsNoTracking() + .ExcludeExactlyReviewed(dbContext) join projection in dbContext.StaffOnboardingProcessingRestrictionProjections .AsNoTracking() @@ -134,9 +136,10 @@ public async Task ListActionableAsync( PageRequest page, CancellationToken cancellationToken) { - WorkspaceStaffOnboarding[] fetched = await ( + WorkspaceStaffOnboardingActionableSummaryDto[] fetched = await ( from application in dbContext.StaffOnboardingApplications .AsNoTracking() + .ExcludeExactlyReviewed(dbContext) join projection in dbContext.StaffOnboardingProcessingRestrictionProjections .AsNoTracking() @@ -158,16 +161,27 @@ join projection in WorkspaceStaffOnboardingProcessingRestrictionContract .CurrentVersion && !projection.IsRestricted - select application + select new WorkspaceStaffOnboardingActionableSummaryDto( + application.Id, + (WorkspaceStaffOnboardingSourceKind)application.SourceKind, + application.SourceId, + application.ClaimId, + application.ClaimVersion, + (WorkspaceStaffOnboardingStatus)application.Status, + application.Version, + application.FailureCode, + application.CreatedAtUtc, + application.LastChangedAtUtc, + application.StaffMemberId.HasValue) ) .OrderBy(application => application.CreatedAtUtc) - .ThenBy(application => application.Id) + .ThenBy(application => application.ApplicationId) .Skip(page.SkipCount) .Take(page.PageSize + 1) .ToArrayAsync(cancellationToken).ConfigureAwait(false); bool hasMore = fetched.Length > page.PageSize; return new WorkspaceStaffOnboardingListResponse( - fetched.Take(page.PageSize).Select(application => application.ToDto()).ToArray(), + fetched.Take(page.PageSize).ToArray(), page.Page, page.PageSize, hasMore); diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingRestorationSuppressionReader.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingRestorationSuppressionReader.cs new file mode 100644 index 00000000..37913a18 --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingRestorationSuppressionReader.cs @@ -0,0 +1,121 @@ +namespace BunkFy.Modules.Workspaces.Persistence.Repositories; + +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Naming; +using Microsoft.EntityFrameworkCore; + +internal sealed class WorkspaceStaffOnboardingRestorationSuppressionReader( + WorkspacesDbContext dbContext) + : IWorkspaceStaffOnboardingRestorationSuppressionReader +{ + public async Task + ReadAsync( + string scopeId, + Guid staffMemberId, + string authSubjectId, + CancellationToken cancellationToken) + { + string subjectId = authSubjectId?.Trim() ?? string.Empty; + if (!TenantIds.TryNormalize(scopeId, out string? normalizedScopeId) || + staffMemberId == Guid.Empty || + subjectId.Length is 0 or > + WorkspaceStaffOnboardingRules.SubjectIdMaxLength) + { + return WorkspaceStaffOnboardingRestorationSuppressionState + .Conflict; + } + + IQueryable candidates = + dbContext.StaffOnboardingApplications + .AsNoTracking() + .Where(application => + application.ScopeId == normalizedScopeId && + application.SubjectId == subjectId && + (application.StaffMemberId == staffMemberId || + application.IdentityAnchorResolutionStaffMemberId == + staffMemberId) && + (application.IdentityAnchorResolutionEventId != null || + application.IdentityAnchorResolutionStaffMemberId != + null || + application + .IdentityAnchorResolutionApplicationVersion != null || + application.IdentityAnchorResolutionDisposition != null || + application.IdentityAnchorResolutionIntentAtUtc != null || + application.IdentityAnchorResolutionObservedAtUtc != null)); + + bool hasConflict = await candidates.AnyAsync( + application => + application.IdentityAnchorResolutionEventId == null || + application.IdentityAnchorResolutionEventId == Guid.Empty || + application.IdentityAnchorExpectedResolutionEventId == null || + application.IdentityAnchorExpectedResolutionEventId != + application.IdentityAnchorResolutionEventId || + (application.IdentityAnchorContinuationEventId != null && + (application.IdentityAnchorContinuationEventId == + application.IdentityAnchorResolutionEventId || + application.IdentityAnchorContinuationEventId == + application.Id)) || + application.StaffMemberId != staffMemberId || + application.IdentityAnchorResolutionStaffMemberId != + staffMemberId || + application.IdentityAnchorResolutionApplicationVersion == null || + application.IdentityAnchorResolutionApplicationVersion <= 0 || + application.IdentityAnchorResolutionApplicationVersion > + application.Version || + application.IdentityAnchorResolutionDisposition == null || + application.IdentityAnchorResolutionIntentAtUtc == null || + (application.IdentityAnchorResolutionObservedAtUtc != null && + application.IdentityAnchorResolutionObservedAtUtc < + application.IdentityAnchorResolutionIntentAtUtc) || + application.VerifiedAccountEmail != null || + application.DisplayName != null || + application.LegalName != null || + application.WorkEmail != null || + application.WorkPhone != null || + application.EmployeeNumber != null || + application.JobTitle != null || + application.Department != null || + !((application.Status == + WorkspaceStaffOnboardingState.Completed && + application.IdentityAnchorResolutionDisposition == + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted) || + (application.Status == + WorkspaceStaffOnboardingState.Rejected && + application.IdentityAnchorResolutionDisposition == + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted) || + (application.Status == + WorkspaceStaffOnboardingState.Superseded && + application.IdentityAnchorResolutionDisposition == + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted) || + (application.Status == + WorkspaceStaffOnboardingState.Expired && + application.IdentityAnchorResolutionDisposition == + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted) || + (application.Status == + WorkspaceStaffOnboardingState.Withdrawn && + application.IdentityAnchorResolutionDisposition == + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted)), + cancellationToken).ConfigureAwait(false); + if (hasConflict) + { + return WorkspaceStaffOnboardingRestorationSuppressionState + .Conflict; + } + + bool isSuppressed = await candidates.AnyAsync( + application => + application.IdentityAnchorResolutionDisposition != + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + cancellationToken).ConfigureAwait(false); + return isSuppressed + ? WorkspaceStaffOnboardingRestorationSuppressionState.Suppressed + : WorkspaceStaffOnboardingRestorationSuppressionState.None; + } +} 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/WorkspaceStaffOnboardingSerializedReadBoundary.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingSerializedReadBoundary.cs new file mode 100644 index 00000000..71b1385d --- /dev/null +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspaceStaffOnboardingSerializedReadBoundary.cs @@ -0,0 +1,60 @@ +namespace BunkFy.Modules.Workspaces.Persistence.Repositories; + +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Application.Ports; +using Gma.Framework.Results; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +internal sealed class WorkspaceStaffOnboardingSerializedReadBoundary( + WorkspacesDbContext dbContext) + : IWorkspaceStaffOnboardingSerializedReadBoundary +{ + public async Task> RunAsync( + Func>> read, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(read); + if (!dbContext.Database.IsRelational()) + { + return await this.RunAdmittedAsync(read, cancellationToken) + .ConfigureAwait(false); + } + + if (dbContext.Database.CurrentTransaction is not null) + { + throw new InvalidOperationException( + "The Staff-onboarding serialized read boundary cannot own a nested transaction."); + } + + await using IDbContextTransaction transaction = await dbContext.Database + .BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + try + { + return await this.RunAdmittedAsync(read, cancellationToken) + .ConfigureAwait(false); + } + finally + { + await transaction.RollbackAsync(CancellationToken.None) + .ConfigureAwait(false); + } + } + + private async Task> RunAdmittedAsync( + Func>> read, + CancellationToken cancellationToken) + { + try + { + await dbContext.AcquireOperationalMutationAdmissionAsync( + cancellationToken).ConfigureAwait(false); + return await read(cancellationToken).ConfigureAwait(false); + } + catch (WorkspaceOperationalMutationRejectedException) + { + return Result.Failure( + WorkspaceOperationalAdmissionErrors.ProcessingRestricted); + } + } +} diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsDiscoveryContributor.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsDiscoveryContributor.cs index 2f38e954..33accac0 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsDiscoveryContributor.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsDiscoveryContributor.cs @@ -159,18 +159,11 @@ private async Task AddOnboardingCandidatesAsync( new WorkspaceStaffOnboardingCandidate( application.Id, application.Version, - application.DisplayName, - application.Status, - application.WorkEmail, - application.VerifiedAccountEmail, - application.WorkPhone)) + application.Status)) .ToArrayAsync(cancellationToken) .ConfigureAwait(false); foreach (WorkspaceStaffOnboardingCandidate record in records) { - string displayName = string.IsNullOrWhiteSpace(record.DisplayName) - ? $"Workspace onboarding: {record.Status}" - : $"{record.DisplayName}: {record.Status}"; candidates.Add(new DataRightsSubjectCandidate( new DataRightsSubjectCoordinate( WorkspacesDataRightsCoordinates.Owner, @@ -178,9 +171,9 @@ private async Task AddOnboardingCandidatesAsync( .StaffOnboardingRecordType, record.Id, record.Version), - displayName, - MaskEmail(record.WorkEmail ?? record.VerifiedAccountEmail), - MaskPhone(record.WorkPhone))); + $"Workspace onboarding: {record.Status}", + EmailHint: null, + PhoneHint: null)); } } @@ -408,38 +401,10 @@ private static bool TryGetLookup( DataRightsSubjectDiscoveryLimits.AccountSubjectIdMaxLength; } - private static string? MaskEmail(string? email) - { - if (string.IsNullOrWhiteSpace(email)) - { - return null; - } - - int separator = email.IndexOf('@'); - return separator <= 0 - ? "***" - : $"{email[0]}***{email[separator..]}"; - } - - private static string? MaskPhone(string? phone) - { - if (string.IsNullOrWhiteSpace(phone)) - { - return null; - } - - string digits = new(phone.Where(char.IsDigit).ToArray()); - return digits.Length >= 4 ? $"***{digits[^4..]}" : "***"; - } - private sealed record WorkspaceStaffOnboardingCandidate( Guid Id, long Version, - string? DisplayName, - WorkspaceStaffOnboardingState Status, - string? WorkEmail, - string? VerifiedAccountEmail, - string? WorkPhone); + WorkspaceStaffOnboardingState Status); private sealed record WorkspaceStaffAccessProcessCandidate( Guid 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..76de9c2d 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportContributor.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportContributor.cs @@ -1,14 +1,25 @@ namespace BunkFy.Modules.Workspaces.Persistence.Repositories; using BunkFy.Modules.DataRights.Contracts; +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application.Ports; using BunkFy.Modules.Workspaces.Contracts; +using BunkFy.Modules.Workspaces.Domain; using BunkFy.Modules.Workspaces.Domain.DataRights; using Gma.Framework.Scoping; +using Gma.Framework.Results; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; internal sealed class WorkspacesDataRightsExportContributor( WorkspacesDbContext dbContext, - IScopeContext scopeContext) : IDataRightsSubjectExportContributor + IScopeContext scopeContext, + IWorkspaceStaffOnboardingSerializedReadBoundary serializedReads, + IWorkspaceStaffOnboardingOperationLock operationLock, + IWorkspaceStaffOnboardingRepository applications, + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader outcomes, + ILogger logger) + : IDataRightsSubjectExportContributor { public const int MaximumChildRecords = 1_000; public const string StaffAccessProfileSnapshotRecordType = @@ -17,6 +28,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 @@ -100,47 +113,92 @@ private async Task ExportOnboardingAsync( IDataRightsExportSink sink, CancellationToken cancellationToken) { - WorkspaceStaffOnboardingDataRightsExport? record = - await dbContext.StaffOnboardingApplications - .AsNoTracking() - .Where(application => - application.ScopeId == tenantId && - application.Id == coordinate.RecordId) - .Select(application => - new WorkspaceStaffOnboardingDataRightsExport( - application.Id, - application.ScopeId, - application.SourceKind, - application.SourceId, - application.ClaimId, - application.ClaimVersion, - application.SubjectId, - application.VerifiedAccountEmail, - application.DisplayName, - application.LegalName, - application.WorkEmail, - application.WorkPhone, - application.EmployeeNumber, - application.JobTitle, - application.Department, - application.Status, - application.StaffMemberId, - application.FailureCode, - application.Version, - application.CreatedAtUtc, - application.LastChangedAtUtc)) - .SingleOrDefaultAsync(cancellationToken) - .ConfigureAwait(false); - if (record is null) + Result result = + await serializedReads.RunAsync( + readToken => this.ExportOnboardingSerializedAsync( + tenantId, + coordinate, + sink, + readToken), + cancellationToken).ConfigureAwait(false); + return result.IsSuccess + ? result.Value + : DataRightsSubjectExportResult.ScopeUnavailable(); + } + + private async Task> + ExportOnboardingSerializedAsync( + string tenantId, + DataRightsSubjectCoordinate coordinate, + IDataRightsExportSink sink, + CancellationToken cancellationToken) + { + WorkspaceStaffOnboardingCoordinate? selected = + await applications.FindCoordinateAsync( + coordinate.RecordId, + cancellationToken).ConfigureAwait(false); + if (selected is null) { - return DataRightsSubjectExportResult.NotFound(); + return Result.Success( + DataRightsSubjectExportResult.NotFound()); } - if (record.Version != coordinate.RecordVersion) + await operationLock.AcquireSourceReadAsync( + selected.SourceId, + cancellationToken).ConfigureAwait(false); + if (!await operationLock.TryAcquireAsync( + coordinate.RecordId, + cancellationToken).ConfigureAwait(false)) { - return DataRightsSubjectExportResult.Stale(); + return Result.Success( + DataRightsSubjectExportResult.NotFound()); + } + + WorkspaceStaffOnboarding? application = + await applications.GetAsync( + coordinate.RecordId, + cancellationToken).ConfigureAwait(false); + if (application is null) + { + return Result.Success( + DataRightsSubjectExportResult.NotFound()); + } + + await applications.ReloadAsync(application, cancellationToken) + .ConfigureAwait(false); + if (!string.Equals( + application.ScopeId, + tenantId, + StringComparison.Ordinal) || + application.Id != selected.ApplicationId || + application.SourceKind != selected.SourceKind || + application.SourceId != selected.SourceId) + { + return Result.Success( + DataRightsSubjectExportResult.ScopeUnavailable()); + } + + if (application.Version != coordinate.RecordVersion) + { + return Result.Success( + DataRightsSubjectExportResult.Stale()); + } + + StaffWorkspaceOnboardingIdentityAnchorOutcome? outcome = + await this.ReadOutcomeAsync(application, cancellationToken) + .ConfigureAwait(false); + if (outcome is null || + !WorkspaceStaffOnboardingExportAuthority.IsAuthorized( + application, + outcome)) + { + return Result.Success( + DataRightsSubjectExportResult.ScopeUnavailable()); } + WorkspaceStaffOnboardingDataRightsExport record = + ToDataRightsExport(application); + WorkspaceStaffOnboardingCorrectionReceipt[] receipts = await dbContext.StaffOnboardingCorrectionReceipts .AsNoTracking() @@ -180,7 +238,8 @@ await dbContext.StaffOnboardingProcessingRestrictionReceipts restrictionReceipts.Length > MaximumChildRecords) { - return DataRightsSubjectExportResult.ScopeUnavailable(); + return Result.Success( + DataRightsSubjectExportResult.ScopeUnavailable()); } await sink.WriteAsync( @@ -273,9 +332,72 @@ await sink.WriteAsync( recordCount = checked(recordCount + 1); } - return DataRightsSubjectExportResult.Success(recordCount); + return Result.Success( + DataRightsSubjectExportResult.Success(recordCount)); } + private async Task< + StaffWorkspaceOnboardingIdentityAnchorOutcome?> ReadOutcomeAsync( + WorkspaceStaffOnboarding application, + CancellationToken cancellationToken) + { + try + { + return await outcomes.ReadAsync( + new StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest( + application.Id, + application.SubjectId), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + when (!cancellationToken.IsCancellationRequested) + { + logger.LogWarning( + "Staff identity-anchor outcome read was cancelled outside the Workspaces export request."); + return null; + } + catch (Exception exception) when (exception is not + OperationCanceledException) + { + logger.LogWarning( + "Staff identity-anchor outcome is unavailable for Workspaces onboarding."); + return null; + } + } + + private static WorkspaceStaffOnboardingDataRightsExport + ToDataRightsExport(WorkspaceStaffOnboarding application) => new( + application.Id, + application.ScopeId, + application.SourceKind, + application.SourceId, + application.ClaimId, + application.ClaimVersion, + application.SubjectId, + application.VerifiedAccountEmail, + application.DisplayName, + application.LegalName, + application.WorkEmail, + application.WorkPhone, + application.EmployeeNumber, + application.JobTitle, + application.Department, + application.Status, + application.StaffMemberId, + application.IdentityAnchorExpectedResolutionEventId, + application.IdentityAnchorContinuationEventId, + application.IdentityAnchorResolutionEventId, + application.IdentityAnchorResolutionStaffMemberId, + application.IdentityAnchorResolutionApplicationVersion, + application.IdentityAnchorResolutionDisposition, + application.IdentityAnchorResolutionIntentAtUtc, + application.IdentityAnchorResolutionObservedAtUtc, + application.IdentityAnchorSweepOrdinal, + application.FailureCode, + application.Version, + application.CreatedAtUtc, + application.LastChangedAtUtc); + private async Task ExportAccessProcessAsync( string tenantId, DataRightsSubjectCoordinate coordinate, @@ -299,6 +421,7 @@ await dbContext.StaffAccessProcesses process.EffectiveOn, process.RequestedBy, process.State, + process.RestorationDisposition, process.FailureCode, process.Version, process.CreatedAtUtc, 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..e97b5796 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportModels.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportModels.cs @@ -21,11 +21,29 @@ internal sealed record WorkspaceStaffOnboardingDataRightsExport( string? Department, WorkspaceStaffOnboardingState Status, Guid? StaffMemberId, + Guid? IdentityAnchorExpectedResolutionEventId, + Guid? IdentityAnchorContinuationEventId, + Guid? IdentityAnchorResolutionEventId, + Guid? IdentityAnchorResolutionStaffMemberId, + long? IdentityAnchorResolutionApplicationVersion, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition? + IdentityAnchorResolutionDisposition, + DateTimeOffset? IdentityAnchorResolutionIntentAtUtc, + DateTimeOffset? IdentityAnchorResolutionObservedAtUtc, + long IdentityAnchorSweepOrdinal, string? FailureCode, long Version, 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, @@ -82,6 +100,7 @@ internal sealed record WorkspaceStaffAccessProcessDataRightsExport( DateOnly EffectiveOn, string RequestedBy, WorkspaceStaffAccessProcessState State, + WorkspaceStaffAccessRestorationDisposition RestorationDisposition, string? FailureCode, long Version, DateTimeOffset CreatedAtUtc, @@ -126,3 +145,45 @@ internal sealed record WorkspaceStaffRetentionCorrelationProofDataRightsExport( int AccessPlanRecordsScrubbed, DateTimeOffset CompletedAtUtc, string CanonicalSha256); + +internal sealed record + WorkspaceStaffIdentityAnchorSweepCheckpointDataRightsExport( + WorkspaceStaffIdentityAnchorSweepCheckpointExportState Checkpoint); + +internal sealed record + WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport( + int ContractVersion, + Guid ReceiptId, + string ScopeId, + Guid OperationId, + Guid ApplicationId, + WorkspaceStaffOnboardingSource SourceKind, + Guid SourceId, + long ExpectedApplicationVersion, + WorkspaceStaffOnboardingState ExpectedApplicationStatus, + long ResultApplicationVersion, + WorkspaceStaffOnboardingState ResultApplicationStatus, + long OrganizationsScopeRevision, + long OrganizationsSourceVersion, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + OrganizationsSourceStatus, + string StaffEvidenceSha256, + Guid ExternalEvidenceManifestId, + string ExternalEvidenceSha256, + string ReviewerId, + DateTimeOffset ReviewedAtUtc, + string CanonicalSha256); + +internal sealed record WorkspaceStaffIdentityAnchorSweepCheckpointExportState( + int ProtocolVersion, + bool HasActiveCycle, + long? CycleUpperOrdinal, + long? AfterOrdinal, + DateTimeOffset? CycleStartedAtUtc, + long CycleScannedCount, + long CycleBacklogCount, + long? LastCompletedUpperOrdinal, + DateTimeOffset? LastCompletedAtUtc, + long LastCompletedScannedCount, + long LastCompletedBacklogCount, + DateTimeOffset UpdatedAtUtc); 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..69ce780e 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportSchema.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesDataRightsExportSchema.cs @@ -11,7 +11,7 @@ namespace BunkFy.Modules.Workspaces.Persistence.Repositories; internal static class WorkspacesDataRightsExportSchema { public const string ExportSchemaId = "workspaces.subject-export"; - public const int ExportSchemaVersion = 1; + public const int ExportSchemaVersion = 2; private const string CatalogResourceName = "BunkFy.Modules.Workspaces.Persistence.DataGovernance." + @@ -29,15 +29,17 @@ internal static class WorkspacesDataRightsExportSchema [ "access-control", "auth", + "messaging", "organizations", "properties", "staff", WorkspacesDataRightsCoordinates.Owner ]; - private static readonly Type[] SourceTypes = + private static readonly Type[] SubjectSourceTypes = [ typeof(WorkspaceStaffOnboardingDataRightsExport), + typeof(WorkspaceStaffDeferredClaimWithdrawalDataRightsExport), typeof( WorkspaceStaffOnboardingCorrectionReceiptDataRightsExport), typeof( @@ -51,6 +53,15 @@ internal static class WorkspacesDataRightsExportSchema typeof(WorkspaceStaffRetentionCorrelationDataRightsExport) ]; + private static readonly Type[] TenantTerminationSourceTypes = + [ + .. SubjectSourceTypes, + typeof( + WorkspaceStaffIdentityAnchorSweepCheckpointDataRightsExport), + typeof( + WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport) + ]; + private static readonly JsonSerializerOptions ValueSerializerOptions = CreateSerializerOptions(); private static readonly Lazy State = @@ -73,7 +84,7 @@ public static DataRightsExportRecord CreateRecord( ArgumentNullException.ThrowIfNull(source); Type sourceType = source.GetType(); - if (!SourceTypes.Contains(sourceType) || + if (!TenantTerminationSourceTypes.Contains(sourceType) || recordId == Guid.Empty || recordVersion <= 0 || string.IsNullOrWhiteSpace(recordType) || @@ -157,7 +168,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) || @@ -170,7 +183,7 @@ private static SchemaState Load() "The Workspaces personal-data catalogue identity is invalid."); } - HashSet expectedMembers = SourceTypes + HashSet expectedMembers = TenantTerminationSourceTypes .SelectMany(type => type .GetProperties(BindingFlags.Instance | BindingFlags.Public) .Select(property => MemberKey(type, property.Name))) @@ -230,10 +243,12 @@ private static SchemaState Load() "."); } - string[] fieldIds = fieldIdsByMember.Values - .Distinct(StringComparer.Ordinal) - .OrderBy(fieldId => fieldId, StringComparer.Ordinal) - .ToArray(); + string[] subjectFieldIds = FieldIdsFor( + SubjectSourceTypes, + fieldIdsByMember); + string[] tenantTerminationFieldIds = FieldIdsFor( + TenantTerminationSourceTypes, + fieldIdsByMember); DataRightsExportDescriptor descriptor = new( WorkspacesDataRightsCoordinates.Owner, catalog.CatalogId, @@ -241,7 +256,7 @@ private static SchemaState Load() catalog.CatalogVersion, ExportSchemaId, ExportSchemaVersion, - Array.AsReadOnly(fieldIds)); + Array.AsReadOnly(subjectFieldIds)); DataRightsExportDescriptor tenantTerminationDescriptor = new( WorkspacesDataRightsCoordinates.Owner, catalog.CatalogId, @@ -249,13 +264,25 @@ private static SchemaState Load() catalog.CatalogVersion, WorkspacesTenantTerminationMetadata.ExportSchemaId, WorkspacesTenantTerminationMetadata.ExportSchemaVersion, - Array.AsReadOnly(fieldIds)); + Array.AsReadOnly(tenantTerminationFieldIds)); return new SchemaState( descriptor, tenantTerminationDescriptor, fieldIdsByMember); } + private static string[] FieldIdsFor( + IEnumerable sourceTypes, + Dictionary fieldIdsByMember) => + sourceTypes + .SelectMany(type => type + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Select(property => + fieldIdsByMember[MemberKey(type, property.Name)])) + .Distinct(StringComparer.Ordinal) + .OrderBy(fieldId => fieldId, StringComparer.Ordinal) + .ToArray(); + private static string MemberKey(Type sourceType, string member) => string.Join('|', sourceType.FullName, member); 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..2169e1a7 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesTenantTerminationExportContributor.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/Repositories/WorkspacesTenantTerminationExportContributor.cs @@ -2,6 +2,7 @@ namespace BunkFy.Modules.Workspaces.Persistence.Repositories; using System.Data; using BunkFy.Modules.DataRights.Contracts; +using BunkFy.Modules.Staff.Contracts; using BunkFy.Modules.Workspaces.Contracts; using BunkFy.Modules.Workspaces.Domain; using BunkFy.Modules.Workspaces.Domain.DataRights; @@ -11,14 +12,26 @@ namespace BunkFy.Modules.Workspaces.Persistence.Repositories; using Gma.Framework.Scoping; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging; using DomainFenceState = - BunkFy.Modules.Workspaces.Domain.Termination.WorkspaceTerminationFenceState; + Domain.Termination.WorkspaceTerminationFenceState; internal sealed class WorkspacesTenantTerminationExportContributor( WorkspacesDbContext dbContext, IScopeContext scopeContext, - ISystemClock clock) : ITenantTerminationExportContributor + ISystemClock clock, + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader outcomes, + ILogger logger) + : ITenantTerminationExportContributor { + internal const string StaffIdentityAnchorSweepCheckpointRecordType = + "staff-identity-anchor-sweep-checkpoint"; + internal const string StaffHistoricalNoProvisionReceiptRecordType = + "staff-historical-no-provision-receipt"; + + private const int IdentityAnchorPreflightPageSize = + StaffWorkspaceOnboardingIdentityAnchorLifecycleLimits.MaximumBatchSize; + public DataRightsExportDescriptor ExportDescriptor => WorkspacesDataRightsExportSchema.TenantTerminationDescriptor; @@ -79,6 +92,15 @@ await this.GetFenceAsync( clock.UtcNow); } + if (!await this.PreflightIdentityAnchorsAsync( + tenantId!, + cancellationToken).ConfigureAwait(false)) + { + return RetryRequired( + "workspace.termination.export-identity-anchor-unavailable", + clock.UtcNow); + } + long recordCount = await this.ExportRecordsAsync( tenantId!, sink, @@ -135,6 +157,109 @@ await transaction.CommitAsync(cancellationToken) } } + private async Task PreflightIdentityAnchorsAsync( + string tenantId, + CancellationToken cancellationToken) + { + Guid? afterApplicationId = null; + while (true) + { + IQueryable query = + dbContext.StaffOnboardingApplications + .AsNoTracking() + .Where(application => application.ScopeId == tenantId); + if (afterApplicationId.HasValue) + { + Guid cursor = afterApplicationId.Value; + query = query.Where(application => + application.Id.CompareTo(cursor) > 0); + } + + WorkspaceStaffOnboarding[] loaded = await query + .OrderBy(application => application.Id) + .Take(IdentityAnchorPreflightPageSize + 1) + .ToArrayAsync(cancellationToken).ConfigureAwait(false); + bool hasMore = loaded.Length > IdentityAnchorPreflightPageSize; + WorkspaceStaffOnboarding[] page = loaded + .Take(IdentityAnchorPreflightPageSize) + .ToArray(); + if (page.Length == 0) + { + return !hasMore; + } + + if (afterApplicationId.HasValue && + page[0].Id.CompareTo(afterApplicationId.Value) <= 0) + { + return false; + } + + IReadOnlyList< + StaffWorkspaceOnboardingIdentityAnchorOutcome> read; + try + { + read = await outcomes.ReadAsync( + page.Select(application => + new StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest( + application.Id, + application.SubjectId)) + .ToArray(), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + when (!cancellationToken.IsCancellationRequested) + { + logger.LogWarning( + "Staff identity-anchor preflight was cancelled outside the Workspaces tenant export request."); + return false; + } + catch (Exception exception) when (exception is not + OperationCanceledException) + { + logger.LogWarning( + "Staff identity-anchor preflight is unavailable for Workspaces tenant export."); + return false; + } + + if (read is null || + read.Count != page.Length || + read.Select(outcome => outcome.ApplicationId) + .Distinct().Count() != read.Count) + { + return false; + } + + Dictionary + byApplication = read.ToDictionary( + outcome => outcome.ApplicationId); + if (page.Any(application => + !byApplication.TryGetValue( + application.Id, + out StaffWorkspaceOnboardingIdentityAnchorOutcome? + outcome) || + !WorkspaceStaffOnboardingExportAuthority.IsAuthorized( + application, + outcome))) + { + return false; + } + + Guid next = page[^1].Id; + if (afterApplicationId.HasValue && + next.CompareTo(afterApplicationId.Value) <= 0) + { + return false; + } + + if (!hasMore) + { + return true; + } + + afterApplicationId = next; + } + } + private async Task ExportRecordsAsync( string tenantId, IDataRightsExportSink sink, @@ -166,6 +291,15 @@ private async Task ExportRecordsAsync( item.Department, item.Status, item.StaffMemberId, + item.IdentityAnchorExpectedResolutionEventId, + item.IdentityAnchorContinuationEventId, + item.IdentityAnchorResolutionEventId, + item.IdentityAnchorResolutionStaffMemberId, + item.IdentityAnchorResolutionApplicationVersion, + item.IdentityAnchorResolutionDisposition, + item.IdentityAnchorResolutionIntentAtUtc, + item.IdentityAnchorResolutionObservedAtUtc, + item.IdentityAnchorSweepOrdinal, item.FailureCode, item.Version, item.CreatedAtUtc, @@ -184,6 +318,116 @@ await WriteAsync( count = checked(count + 1); } + await foreach ( + WorkspaceStaffIdentityAnchorSweepCheckpoint checkpoint in + dbContext.StaffIdentityAnchorSweepCheckpoints + .AsNoTracking() + .Where(item => item.ScopeId == tenantId) + .OrderBy(item => item.Id) + .AsAsyncEnumerable() + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + WorkspaceStaffIdentityAnchorSweepPageCounts current = + checkpoint.CurrentCounts(); + WorkspaceStaffIdentityAnchorSweepPageCounts lastCompleted = + checkpoint.LastCompletedCounts(); + WorkspaceStaffIdentityAnchorSweepCheckpointDataRightsExport + record = new( + new WorkspaceStaffIdentityAnchorSweepCheckpointExportState( + checkpoint.ProtocolVersion, + checkpoint.HasActiveCycle, + checkpoint.CycleUpperOrdinal, + checkpoint.AfterOrdinal, + checkpoint.CycleStartedAtUtc, + current.ScannedCount, + current.BacklogCount, + checkpoint.LastCompletedUpperOrdinal, + checkpoint.LastCompletedAtUtc, + lastCompleted.ScannedCount, + lastCompleted.BacklogCount, + checkpoint.UpdatedAtUtc)); + await WriteAsync( + StaffIdentityAnchorSweepCheckpointRecordType, + checkpoint.Id, + checkpoint.Version, + record, + sink, + cancellationToken).ConfigureAwait(false); + count = checked(count + 1); + } + + await foreach ( + WorkspaceStaffHistoricalNoProvisionReceipt receipt in + dbContext.StaffHistoricalNoProvisionReceipts + .AsNoTracking() + .Where(item => item.ScopeId == tenantId) + .OrderBy(item => item.Id) + .AsAsyncEnumerable() + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport + record = new( + receipt.ContractVersion, + receipt.Id, + receipt.ScopeId, + receipt.OperationId, + receipt.ApplicationId, + receipt.SourceKind, + receipt.SourceId, + receipt.ExpectedApplicationVersion, + receipt.ExpectedApplicationStatus, + receipt.ResultApplicationVersion, + receipt.ResultApplicationStatus, + receipt.OrganizationsScopeRevision, + receipt.OrganizationsSourceVersion, + receipt.OrganizationsSourceStatus, + receipt.StaffEvidenceSha256, + receipt.ExternalEvidenceManifestId, + receipt.ExternalEvidenceSha256, + receipt.ReviewerId, + receipt.ReviewedAtUtc, + receipt.CanonicalSha256); + await WriteAsync( + StaffHistoricalNoProvisionReceiptRecordType, + receipt.Id, + receipt.ContractVersion, + record, + sink, + cancellationToken).ConfigureAwait(false); + 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() @@ -304,6 +548,7 @@ await WriteAsync( item.EffectiveOn, item.RequestedBy, item.State, + item.RestorationDisposition, item.FailureCode, item.Version, item.CreatedAtUtc, diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/TenantTermination/WorkspaceTenantDestroyOperation.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/TenantTermination/WorkspaceTenantDestroyOperation.cs index bdb6bf1a..fd0d317b 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/TenantTermination/WorkspaceTenantDestroyOperation.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/TenantTermination/WorkspaceTenantDestroyOperation.cs @@ -179,5 +179,9 @@ internal enum WorkspaceTenantDestroyStage AnonymisationReceipts = 17, TerminationFenceReceipts = 18, HistoricalTerminationFences = 19, - Completed = 20 + SweepCheckpoints = 20, + HistoricalNoProvisionReceipts = 21, + // The final migration must translate any persisted legacy completion + // ordinal to Completed (22) before replacing the stage check constraint. + Completed = 22 } 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..c3b0b14a 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( @@ -174,6 +171,23 @@ private Task RemoveCurrentStageAsync( fence.Id != operation.FenceId), fence => fence.Id, cancellationToken), + WorkspaceTenantDestroyStage.SweepCheckpoints => + this.RemoveGuidBatchAsync( + operation, + dbContext.StaffIdentityAnchorSweepCheckpoints + .IgnoreQueryFilters() + .Where(checkpoint => + checkpoint.ScopeId == tenantId), + checkpoint => checkpoint.Id, + cancellationToken), + WorkspaceTenantDestroyStage.HistoricalNoProvisionReceipts => + this.RemoveGuidBatchAsync( + operation, + dbContext.StaffHistoricalNoProvisionReceipts + .IgnoreQueryFilters() + .Where(receipt => receipt.ScopeId == tenantId), + receipt => receipt.Id, + cancellationToken), _ => throw new InvalidDataException( "The Workspaces tenant destruction stage is invalid.") }; @@ -193,6 +207,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 +404,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) @@ -371,6 +427,18 @@ await dbContext.StaffCorrelationAnonymisationReceipts await dbContext.WorkspaceTerminationFenceReceipts .IgnoreQueryFilters() .AnyAsync(receipt => receipt.ScopeId == tenantId, cancellationToken) + .ConfigureAwait(false) || + await dbContext.StaffIdentityAnchorSweepCheckpoints + .IgnoreQueryFilters() + .AnyAsync( + checkpoint => checkpoint.ScopeId == tenantId, + cancellationToken) + .ConfigureAwait(false) || + await dbContext.StaffHistoricalNoProvisionReceipts + .IgnoreQueryFilters() + .AnyAsync( + receipt => receipt.ScopeId == tenantId, + cancellationToken) .ConfigureAwait(false); if (hasOwnedRecords) { diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/TenantTermination/WorkspaceTenantDestructionOwner.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/TenantTermination/WorkspaceTenantDestructionOwner.cs index 7400aaac..b50211a6 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/TenantTermination/WorkspaceTenantDestructionOwner.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/TenantTermination/WorkspaceTenantDestructionOwner.cs @@ -198,6 +198,7 @@ await dbContext.TenantDestroyOperations await dbContext.SaveTenantDestructionChangesAsync( tenantId, request.IdempotencyKey, + attemptedStage: null, cancellationToken) .ConfigureAwait(false); } @@ -238,6 +239,7 @@ await dbContext.SaveTenantDestructionChangesAsync( while (!operation.IsComplete) { + WorkspaceTenantDestroyStage attemptedStage = operation.Stage; bool removed = await this.RemoveCurrentStageAsync( operation, tenantId, @@ -251,6 +253,7 @@ await dbContext.SaveTenantDestructionChangesAsync( await dbContext.SaveTenantDestructionChangesAsync( tenantId, request.IdempotencyKey, + attemptedStage, cancellationToken) .ConfigureAwait(false); return await FinishAsync( @@ -319,6 +322,7 @@ await dbContext.SaveTenantDestructionChangesAsync( await dbContext.SaveTenantDestructionChangesAsync( tenantId, request.IdempotencyKey, + attemptedStage: null, cancellationToken) .ConfigureAwait(false); return await FinishAsync( diff --git a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/WorkspacesDbContext.cs b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/WorkspacesDbContext.cs index c23664b2..fd261d2f 100644 --- a/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/WorkspacesDbContext.cs +++ b/src/Modules/Workspaces/BunkFy.Modules.Workspaces.Persistence/WorkspacesDbContext.cs @@ -1,5 +1,6 @@ namespace BunkFy.Modules.Workspaces.Persistence; +using System.Globalization; using BunkFy.Modules.Workspaces.Domain; using BunkFy.Modules.Workspaces.Domain.DataRights; using BunkFy.Modules.Workspaces.Domain.Termination; @@ -20,6 +21,15 @@ public sealed class WorkspacesDbContext( public DbSet StaffOnboardingApplications => this.Set(); + public DbSet + StaffIdentityAnchorSweepCheckpoints => + this.Set(); + public DbSet + StaffHistoricalNoProvisionReceipts => + this.Set(); + public DbSet + StaffDeferredClaimWithdrawals => + this.Set(); public DbSet StaffOnboardingCorrectionReceipts => this.Set(); @@ -102,6 +112,10 @@ private void EnsureReceiptsAreAppendOnly() .Any(entry => entry.State is EntityState.Modified or EntityState.Deleted); + bool historicalNoProvisionMutationRequested = this.ChangeTracker + .Entries() + .Any(entry => + entry.State is EntityState.Modified or EntityState.Deleted); bool correctionMutationRequested = this.ChangeTracker .Entries() .Any(entry => @@ -138,6 +152,7 @@ entry.State is .Any(entry => entry.State is EntityState.Modified or EntityState.Deleted); if (retentionMutationRequested || + historicalNoProvisionMutationRequested || correctionMutationRequested || restrictionMutationRequested || anonymisationMutationRequested || @@ -391,10 +406,13 @@ await WorkspaceTenantMutationLock.AcquireAdmissionAsync( internal async Task SaveTenantDestructionChangesAsync( string tenantId, Guid operationId, + WorkspaceTenantDestroyStage? attemptedStage, CancellationToken cancellationToken) { if (!TenantIds.TryNormalize(tenantId, out string? canonicalTenantId) || operationId == Guid.Empty || + attemptedStage is <= WorkspaceTenantDestroyStage.Unknown or + >= WorkspaceTenantDestroyStage.Completed || !this.scopeContext.IsEnabled || !string.Equals( this.scopeContext.ScopeId, @@ -446,10 +464,18 @@ WorkspaceTerminationFenceState.DestructionStarted or if (this.Database.IsNpgsql()) { + string attemptedStageValue = attemptedStage.HasValue + ? ((int)attemptedStage.Value).ToString( + CultureInfo.InvariantCulture) + : string.Empty; await this.Database.ExecuteSqlInterpolatedAsync( $"SELECT set_config('bunkfy.workspaces_tenant_destroy_operation_id', {operationId.ToString("D")}, true)", cancellationToken) .ConfigureAwait(false); + await this.Database.ExecuteSqlInterpolatedAsync( + $"SELECT set_config('bunkfy.workspaces_tenant_destroy_attempted_stage', {attemptedStageValue}, true)", + cancellationToken) + .ConfigureAwait(false); } return await base.SaveChangesAsync( diff --git a/src/Modules/Workspaces/docs/README.md b/src/Modules/Workspaces/docs/README.md index 3939e9ad..596ad904 100644 --- a/src/Modules/Workspaces/docs/README.md +++ b/src/Modules/Workspaces/docs/README.md @@ -75,6 +75,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 @@ -119,6 +138,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 6e63f970..5ac16514 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": 15, "module": "workspaces", "approvalState": "engineering-default", "accessPolicies": [ @@ -11,7 +11,8 @@ "readers": [ "permission:access-control.profiles.read", "permission:staff.sensitive-profile.read", - "system:authorized-audit-consumer" + "system:authorized-audit-consumer", + "system:workspaces-identity-anchor-cutover" ], "writers": [ "system:workspaces-access-coordinator" @@ -50,6 +51,19 @@ "system:authorized-module-outbox" ] }, + { + "id": "workspaces-identity-anchor-cutover", + "scope": "tenant-identity-anchor-cutover-evidence", + "readers": [ + "permission:workspaces.identity-anchors.reconcile", + "system:authorized-audit-consumer", + "system:workspaces-identity-anchor-cutover" + ], + "writers": [ + "permission:workspaces.identity-anchors.reconcile", + "system:workspaces-identity-anchor-cutover" + ] + }, { "id": "workspaces-internal", "scope": "tenant-internal", @@ -151,6 +165,19 @@ "writers": [ "system:authorized-tenant-termination-owner" ] + }, + { + "id": "workspaces-identity-anchor-historical-no-provision", + "scope": "tenant-reviewed-historical-no-provision-proof", + "readers": [ + "permission:workspaces.identity-anchors.historical-no-provision.review", + "system:authorized-audit-consumer", + "system:workspaces-identity-anchor-cutover" + ], + "writers": [ + "permission:workspaces.identity-anchors.historical-no-provision.review", + "system:workspaces-identity-anchor-cutover" + ] } ], "retentionPolicies": [ @@ -189,6 +216,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", @@ -265,6 +299,13 @@ "startsAt": "approved-workspaces-tenant-destruction-started", "endsAt": "approved-minimum-termination-proof-retention-completed", "legalHoldBehavior": "retain-minimum-required-termination-proof" + }, + { + "id": "workspace-historical-no-provision-proof", + "approvalState": "engineering-default", + "startsAt": "workspace-historical-no-provision-review-completed", + "endsAt": "approved-audit-retention-expired-or-tenant-termination", + "legalHoldBehavior": "pause-approved-disposal" } ], "rightsPolicies": [ @@ -282,6 +323,13 @@ "restriction": "retain-minimum-required-audit-attribution", "erasure": "pseudonymize-subject-to-minimum-audit-receipt" }, + { + "id": "identity-anchor-cutover-evidence-control", + "export": "include-in-authorized-tenant-cutover-evidence", + "correction": "regenerate-from-authoritative-source-evidence", + "restriction": "restrict-to-authorized-cutover-operators", + "erasure": "discard-on-cutover-request-completion" + }, { "id": "data-rights-correction-accountability", "export": "include-in-authorized-audit-or-tenant-export", @@ -337,6 +385,13 @@ "correction": "append-a-corrective-control-plane-record", "restriction": "retain-only-the-minimum-owner-proof", "erasure": "remove-after-approved-proof-retention" + }, + { + "id": "workspace-historical-no-provision-proof-control", + "export": "include-in-authorized-audit-or-tenant-export", + "correction": "append-corrective-proof-only", + "restriction": "retain-minimum-owner-proof", + "erasure": "retain-minimum-approved-accountability-proof" } ], "fields": [ @@ -1379,13 +1434,33 @@ "surface": "data-rights-export", "retentionPolicy": "workspaces-data-rights-export-fragment" }, - { - "assembly": "BunkFy.Modules.Workspaces.Persistence", - "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffAccessPlanDataRightsExport", - "member": "CreatedBySubjectId", - "surface": "data-rights-export", - "retentionPolicy": "workspaces-data-rights-export-fragment" - } + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffAccessPlanDataRightsExport", + "member": "CreatedBySubjectId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "ReviewerId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand", + "member": "ReviewerId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "ReviewerId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } ] }, { @@ -1458,7 +1533,8 @@ "application-query", "data-rights-export", "integration-event", - "persistence" + "persistence", + "projection-export" ], "allowedBoundaries": [ "cross-module", @@ -1500,6 +1576,20 @@ "surface": "application-query", "retentionPolicy": "transient-request" }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Ports.WorkspaceStaffIdentityAnchorSourceRecord", + "member": "SubjectId", + "surface": "projection-export", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Ports.IWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence", + "member": "CanMutateAsync.subjectId", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingDto", @@ -1731,6 +1821,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" } ] }, @@ -1847,6 +1951,205 @@ "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" + } + ] + }, + { + "id": "workspaces.identity-anchor-cutover.historical-evidence-digest", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "historical-owner-universe-review", + "cutover-completeness-evidence" + ], + "sources": [ + "operator-reviewed-historical-owner-evidence" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-cutover", + "countryPolicyKey": "workspaces.identity-anchor-cutover-evidence", + "retentionPolicy": "transient-response", + "rightsPolicy": "identity-anchor-cutover-evidence-control", + "allowedSurfaces": [ + "admin-output", + "application-command", + "application-query" + ], + "allowedBoundaries": [ + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.WorkspaceStaffIdentityAnchorHistoricalEvidence", + "member": "EvidenceSha256", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.WorkspaceStaffIdentityAnchorHistoricalEvidence", + "member": "EvidenceSha256", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.WorkspaceStaffIdentityAnchorCutoverStatus", + "member": "HistoricalEvidenceSha256", + "surface": "admin-output", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "workspaces.identity-anchor-cutover.owner-manifest-digest", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "reviewed-owner-binding-evidence", + "stale-decision-prevention" + ], + "sources": [ + "operator-reviewed-organizations-export" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-cutover", + "countryPolicyKey": "workspaces.identity-anchor-cutover-evidence", + "retentionPolicy": "transient-response", + "rightsPolicy": "identity-anchor-cutover-evidence-control", + "allowedSurfaces": [ + "admin-output", + "application-command" + ], + "allowedBoundaries": [ + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.WorkspaceStaffIdentityAnchorCutoverStatus", + "member": "OwnerManifestSha256", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Commands.ReconcileWorkspaceStaffIdentityAnchorsCommand", + "member": "ExpectedOwnerManifestSha256", + "surface": "application-command", + "retentionPolicy": "transient-request" + } + ] + }, + { + "id": "workspaces.identity-anchor-cutover.source-evidence-digest", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "authoritative-source-evidence", + "stale-decision-prevention" + ], + "sources": [ + "organizations-scope-export", + "workspaces-onboarding" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-cutover", + "countryPolicyKey": "workspaces.identity-anchor-cutover-evidence", + "retentionPolicy": "transient-response", + "rightsPolicy": "identity-anchor-cutover-evidence-control", + "allowedSurfaces": [ + "admin-output", + "application-command" + ], + "allowedBoundaries": [ + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.WorkspaceStaffIdentityAnchorCutoverStatus", + "member": "SourceEvidenceSha256", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Commands.ReconcileWorkspaceStaffIdentityAnchorsCommand", + "member": "ExpectedSourceEvidenceSha256", + "surface": "application-command", + "retentionPolicy": "transient-request" + } + ] + }, + { + "id": "workspaces.identity-anchor-cutover.state-digest", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "anchor-state-review", + "cutover-conflict-evidence" + ], + "sources": [ + "staff-identity-anchor-inspection" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-cutover", + "countryPolicyKey": "workspaces.identity-anchor-cutover-evidence", + "retentionPolicy": "transient-response", + "rightsPolicy": "identity-anchor-cutover-evidence-control", + "allowedSurfaces": [ + "admin-output", + "application-command" + ], + "allowedBoundaries": [ + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.WorkspaceStaffIdentityAnchorCutoverStatus", + "member": "AnchorStateSha256", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Commands.ReconcileWorkspaceStaffIdentityAnchorsCommand", + "member": "ExpectedAnchorStateSha256", + "surface": "application-command", + "retentionPolicy": "transient-request" } ] }, @@ -1869,10 +2172,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": [ @@ -1938,6 +2244,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" } ] }, @@ -1960,10 +2280,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": [ @@ -2029,6 +2352,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" } ] }, @@ -2592,11 +2929,25 @@ "member": "PlanId", "surface": "data-rights-export", "retentionPolicy": "workspaces-data-rights-export-fragment" - } - ] - }, - { - "id": "workspaces.join-source-kind", + }, + { + "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" + } + ] + }, + { + "id": "workspaces.join-source-kind", "dataSubject": "staff", "classification": "linked-operational", "sensitivity": "standard", @@ -3207,7 +3558,8 @@ "api-response", "application-command", "data-rights-export", - "persistence" + "persistence", + "projection-export" ], "allowedBoundaries": [ "cross-module", @@ -3229,6 +3581,20 @@ "surface": "application-command", "retentionPolicy": "transient-request" }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Ports.WorkspaceStaffIdentityAnchorSourceRecord", + "member": "ApplicationId", + "surface": "projection-export", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Ports.WorkspaceStaffIdentityAnchorSourcePage", + "member": "NextApplicationId", + "surface": "projection-export", + "retentionPolicy": "transient-response" + }, { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingDto", @@ -3370,7 +3736,8 @@ "allowedSurfaces": [ "api-response", "data-rights-export", - "persistence" + "persistence", + "projection-export" ], "allowedBoundaries": [ "cross-module", @@ -3385,6 +3752,13 @@ "member": "Status", "surface": "persistence" }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Ports.WorkspaceStaffIdentityAnchorSourceRecord", + "member": "Status", + "surface": "projection-export", + "retentionPolicy": "transient-response" + }, { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingDto", @@ -4324,10 +4698,12 @@ "rightsPolicy": "transient-request-data", "allowedSurfaces": [ "admin-output", - "api-response" + "api-response", + "projection-export" ], "allowedBoundaries": [ "customer-api", + "intra-module", "support" ], "approvalState": "engineering-default", @@ -4352,6 +4728,13 @@ "member": "Items", "surface": "admin-output", "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Ports.WorkspaceStaffIdentityAnchorSourcePage", + "member": "Records", + "surface": "projection-export", + "retentionPolicy": "transient-response" } ] }, @@ -4448,7 +4831,8 @@ "application-command", "data-rights-export", "integration-event", - "persistence" + "persistence", + "projection-export" ], "allowedBoundaries": [ "cross-module", @@ -4490,6 +4874,13 @@ "surface": "application-command", "retentionPolicy": "transient-request" }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Ports.WorkspaceStaffIdentityAnchorSourceRecord", + "member": "StaffMemberId", + "surface": "projection-export", + "retentionPolicy": "transient-response" + }, { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingDto", @@ -4698,6 +5089,7 @@ "admin-output", "api-response", "application-command", + "application-query", "data-rights-export", "integration-event", "persistence" @@ -4921,6 +5313,34 @@ "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" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Ports.IWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence", + "member": "CanMutateAsync.tenantId", + "surface": "application-query", + "retentionPolicy": "transient-request" } ] }, @@ -9787,6 +10207,1847 @@ "retentionPolicy": "transient-response" } ] + }, + { + "id": "workspaces.identity-anchor.expected-resolution-event-id", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-resolution", + "message-idempotency" + ], + "sources": [ + "staff" + ], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-onboarding", + "countryPolicyKey": "workspaces.identity-anchor.expected-resolution-event-id", + "retentionPolicy": "workspace-onboarding-lifecycle", + "rightsPolicy": "staff-employment-history", + "allowedSurfaces": [ + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding", + "member": "IdentityAnchorExpectedResolutionEventId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport", + "member": "IdentityAnchorExpectedResolutionEventId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor.continuation-event-id", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-continuation", + "message-idempotency" + ], + "sources": [ + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-onboarding", + "countryPolicyKey": "workspaces.identity-anchor.continuation-event-id", + "retentionPolicy": "workspace-onboarding-lifecycle", + "rightsPolicy": "staff-employment-history", + "allowedSurfaces": [ + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding", + "member": "IdentityAnchorContinuationEventId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport", + "member": "IdentityAnchorContinuationEventId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor.resolution-event-id", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-resolution", + "message-idempotency" + ], + "sources": [ + "staff" + ], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-onboarding", + "countryPolicyKey": "workspaces.identity-anchor.resolution-event-id", + "retentionPolicy": "workspace-onboarding-lifecycle", + "rightsPolicy": "staff-employment-history", + "allowedSurfaces": [ + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding", + "member": "IdentityAnchorResolutionEventId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport", + "member": "IdentityAnchorResolutionEventId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor.resolution-staff-member-id", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-resolution", + "staff-linkage" + ], + "sources": [ + "staff" + ], + "authoritativeOwner": "staff", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-onboarding", + "countryPolicyKey": "workspaces.identity-anchor.resolution-staff-member-id", + "retentionPolicy": "workspace-onboarding-lifecycle", + "rightsPolicy": "staff-employment-history", + "allowedSurfaces": [ + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding", + "member": "IdentityAnchorResolutionStaffMemberId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport", + "member": "IdentityAnchorResolutionStaffMemberId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor.resolution-application-version", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "identity-anchor-resolution", + "exact-replay" + ], + "sources": [ + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-onboarding", + "countryPolicyKey": "workspaces.identity-anchor.resolution-application-version", + "retentionPolicy": "workspace-onboarding-lifecycle", + "rightsPolicy": "staff-employment-history", + "allowedSurfaces": [ + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding", + "member": "IdentityAnchorResolutionApplicationVersion", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport", + "member": "IdentityAnchorResolutionApplicationVersion", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor.resolution-disposition", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-resolution", + "access-restoration-suppression" + ], + "sources": [ + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-onboarding", + "countryPolicyKey": "workspaces.identity-anchor.resolution-disposition", + "retentionPolicy": "workspace-onboarding-lifecycle", + "rightsPolicy": "staff-employment-history", + "allowedSurfaces": [ + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding", + "member": "IdentityAnchorResolutionDisposition", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport", + "member": "IdentityAnchorResolutionDisposition", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor.resolution-intent-at-utc", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "identity-anchor-resolution", + "audit-correlation" + ], + "sources": [ + "system-clock" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-onboarding", + "countryPolicyKey": "workspaces.identity-anchor.resolution-intent-at-utc", + "retentionPolicy": "workspace-onboarding-lifecycle", + "rightsPolicy": "staff-employment-history", + "allowedSurfaces": [ + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding", + "member": "IdentityAnchorResolutionIntentAtUtc", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport", + "member": "IdentityAnchorResolutionIntentAtUtc", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor.resolution-observed-at-utc", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "identity-anchor-resolution", + "reconciliation" + ], + "sources": [ + "system-clock" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-onboarding", + "countryPolicyKey": "workspaces.identity-anchor.resolution-observed-at-utc", + "retentionPolicy": "workspace-onboarding-lifecycle", + "rightsPolicy": "staff-employment-history", + "allowedSurfaces": [ + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding", + "member": "IdentityAnchorResolutionObservedAtUtc", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport", + "member": "IdentityAnchorResolutionObservedAtUtc", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor.sweep-ordinal", + "dataSubject": "staff", + "classification": "linked-operational", + "sensitivity": "standard", + "purposes": [ + "identity-anchor-reconciliation", + "bounded-keyset-paging" + ], + "sources": [ + "database-sequence" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-onboarding", + "countryPolicyKey": "workspaces.identity-anchor.sweep-ordinal", + "retentionPolicy": "workspace-onboarding-lifecycle", + "rightsPolicy": "staff-employment-history", + "allowedSurfaces": [ + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding", + "member": "IdentityAnchorSweepOrdinal", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport", + "member": "IdentityAnchorSweepOrdinal", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.access-process-restoration-disposition", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "elevated", + "purposes": [ + "access-restoration", + "access-restoration-suppression" + ], + "sources": [ + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-access-history", + "countryPolicyKey": "workspaces.access-process-restoration-disposition", + "retentionPolicy": "workspace-access-history", + "rightsPolicy": "staff-employment-history", + "allowedSurfaces": [ + "admin-output", + "data-rights-export", + "persistence", + "projection-export" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessProcess", + "member": "RestorationDisposition", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffAccessProcessDataRightsExport", + "member": "RestorationDisposition", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Commands.WorkspaceStaffAccessPreparation", + "member": "RestorationDisposition", + "surface": "projection-export", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Contracts", + "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffAccessProcessDto", + "member": "RestorationDisposition", + "surface": "admin-output", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "workspaces.identity-anchor-sweep.coordinate", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-reconciliation", + "message-idempotency" + ], + "sources": [ + "workspaces-domain", + "staff" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-onboarding", + "countryPolicyKey": "workspaces.identity-anchor-sweep.coordinate", + "retentionPolicy": "workspace-onboarding-lifecycle", + "rightsPolicy": "staff-employment-history", + "allowedSurfaces": [ + "admin-output", + "application-command", + "domain-event", + "integration-event", + "persistence", + "projection-export" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "Id", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "ScopeId", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "CycleId", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "LastCompletedCycleId", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "LastAdvanceId", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "LastAdvanceSha256", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "LastRunId", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Commands.PrepareWorkspaceStaffIdentityAnchorSweepPageCommand", "member": "CheckpointId", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Commands.PrepareWorkspaceStaffIdentityAnchorSweepPageCommand", "member": "CycleId", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Commands.PrepareWorkspaceStaffIdentityAnchorSweepPageCommand", "member": "EmptyAdvanceId", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Commands.PrepareWorkspaceStaffIdentityAnchorSweepPageCommand", "member": "RunId", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Commands.ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommand", "member": "ApplicationId", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance", "member": "CheckpointId", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance", "member": "ExpectedCycleId", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance", "member": "AdvanceId", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance", "member": "RunId", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepCandidate", "member": "ApplicationId", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepCandidate", "member": "SubjectId", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage", "member": "CheckpointId", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage", "member": "CycleId", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepCandidateResult", "member": "ApplicationId", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "ScopeId", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "CycleId", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "LastCompletedCycleId", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "LastRunId", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent", "member": "EventId", "surface": "domain-event", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent", "member": "ScopeId", "surface": "domain-event", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent", "member": "ApplicationId", "surface": "domain-event", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent", "member": "StaffMemberId", "surface": "domain-event", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent", "member": "EventId", "surface": "domain-event", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent", "member": "ScopeId", "surface": "domain-event", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent", "member": "ApplicationId", "surface": "domain-event", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent", "member": "StaffMemberId", "surface": "domain-event", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent", "member": "EventId", "surface": "integration-event", "retentionPolicy": "integration-message-journal" }, + { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent", "member": "ScopeId", "surface": "integration-event", "retentionPolicy": "integration-message-journal" }, + { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent", "member": "ApplicationId", "surface": "integration-event", "retentionPolicy": "integration-message-journal" }, + { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent", "member": "StaffMemberId", "surface": "integration-event", "retentionPolicy": "integration-message-journal" }, + { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent", "member": "EventId", "surface": "integration-event", "retentionPolicy": "integration-message-journal" }, + { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent", "member": "ScopeId", "surface": "integration-event", "retentionPolicy": "integration-message-journal" }, + { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent", "member": "ApplicationId", "surface": "integration-event", "retentionPolicy": "integration-message-journal" }, + { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent", "member": "StaffMemberId", "surface": "integration-event", "retentionPolicy": "integration-message-journal" } + ] + }, + { + "id": "workspaces.identity-anchor-sweep.lifecycle", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-reconciliation", + "identity-anchor-resolution" + ], + "sources": [ + "workspaces-domain", + "staff" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-onboarding", + "countryPolicyKey": "workspaces.identity-anchor-sweep.lifecycle", + "retentionPolicy": "workspace-onboarding-lifecycle", + "rightsPolicy": "staff-employment-history", + "allowedSurfaces": [ + "admin-output", + "application-command", + "domain-event", + "integration-event", + "persistence", + "projection-export" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "ProtocolVersion", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "CycleStartedAtUtc", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "LastCompletedAtUtc", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "UpdatedAtUtc", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepCandidate", "member": "HasLocalAnchorState", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage", "member": "ReachedEnd", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage", "member": "AdvanceRequired", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance", "member": "ReachedEnd", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepCandidateResult", "member": "Outcome", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "HasCheckpoint", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "ProtocolVersion", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "HasActiveCycle", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "CycleStartedAtUtc", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "LastCompletedAtUtc", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "UpdatedAtUtc", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "HasCompletedBoundedCycle", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "HasCompletedCycleObservation", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent", "member": "OccurredAtUtc", "surface": "domain-event", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent", "member": "OccurredAtUtc", "surface": "domain-event", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent", "member": "WorkspaceApplicationVersion", "surface": "domain-event", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent", "member": "Disposition", "surface": "domain-event", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent", "member": "OccurredAtUtc", "surface": "integration-event", "retentionPolicy": "integration-message-journal" }, + { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent", "member": "OccurredAtUtc", "surface": "integration-event", "retentionPolicy": "integration-message-journal" }, + { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent", "member": "WorkspaceApplicationVersion", "surface": "integration-event", "retentionPolicy": "integration-message-journal" }, + { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent", "member": "Disposition", "surface": "integration-event", "retentionPolicy": "integration-message-journal" } + ] + }, + { + "id": "workspaces.identity-anchor-sweep.operational-control", + "dataSubject": "staff", + "classification": "linked-operational", + "sensitivity": "standard", + "purposes": [ + "identity-anchor-reconciliation", + "bounded-backlog-observation" + ], + "sources": [ + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-onboarding", + "countryPolicyKey": "workspaces.identity-anchor-sweep.operational-control", + "retentionPolicy": "workspace-onboarding-lifecycle", + "rightsPolicy": "staff-employment-history", + "allowedSurfaces": [ + "admin-output", + "application-command", + "persistence", + "projection-export" + ], + "allowedBoundaries": [ + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "Version", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "CycleUpperOrdinal", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "AfterOrdinal", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "CycleScannedCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "CycleNoAnchorCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "CycleRemovedCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "CycleObservedCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "CycleAlreadyObservedCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "CycleDeferredCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "CycleConflictCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "CyclePassOneCommittedCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "CycleResolutionRecordConfirmedCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "LastCompletedUpperOrdinal", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "LastCompletedScannedCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "LastCompletedNoAnchorCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "LastCompletedRemovedCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "LastCompletedObservedCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "LastCompletedAlreadyObservedCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "LastCompletedDeferredCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "LastCompletedConflictCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "LastCompletedPassOneCommittedCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint", "member": "LastCompletedResolutionRecordConfirmedCount", "surface": "persistence" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Commands.PrepareWorkspaceStaffIdentityAnchorSweepPageCommand", "member": "BatchSize", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.ReconcileWorkspaceStaffIdentityAnchorsPayload", "member": "BatchSize", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Contracts", "type": "BunkFy.Modules.Workspaces.Contracts.ReconcileWorkspaceStaffIdentityAnchorsPayload", "member": "MaxBatches", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepCandidate", "member": "IdentityAnchorSweepOrdinal", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage", "member": "CheckpointVersion", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage", "member": "UpperOrdinal", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage", "member": "ExpectedAfterOrdinal", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage", "member": "NextAfterOrdinal", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance", "member": "ExpectedCheckpointVersion", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance", "member": "ExpectedAfterOrdinal", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance", "member": "NextAfterOrdinal", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "CheckpointVersion", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "CycleUpperOrdinal", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "AfterOrdinal", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "LastCompletedUpperOrdinal", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "LastCompletedObservedBacklogCount", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "ScannedCount", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "NoAnchorCount", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "RemovedCount", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "ObservedCount", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "AlreadyObservedCount", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "DeferredCount", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "ConflictCount", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "PassOneCommittedCount", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "ResolutionRecordConfirmedCount", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "SettledCount", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "BacklogCount", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "ScannedCount", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "NoAnchorCount", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "RemovedCount", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "ObservedCount", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "AlreadyObservedCount", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "DeferredCount", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "ConflictCount", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "PassOneCommittedCount", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "ResolutionRecordConfirmedCount", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "SettledCount", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Domain", "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts", "member": "BacklogCount", "surface": "admin-output", "retentionPolicy": "transient-response" } + ] + }, + { + "id": "workspaces.identity-anchor-sweep.structured-control", + "dataSubject": "staff", + "classification": "structured-payload", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-reconciliation" + ], + "sources": [ + "workspaces-domain", + "staff" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-onboarding", + "countryPolicyKey": "workspaces.identity-anchor-sweep.structured-control", + "retentionPolicy": "workspace-onboarding-lifecycle", + "rightsPolicy": "staff-employment-history", + "allowedSurfaces": [ + "admin-output", + "application-command", + "data-rights-export", + "projection-export" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Commands.AdvanceWorkspaceStaffIdentityAnchorSweepCommand", "member": "Advance", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage", "member": "Candidates", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance", "member": "Counts", "surface": "application-command", "retentionPolicy": "transient-request" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepCandidateResult", "member": "ResolutionRequest", "surface": "projection-export", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "CurrentCycle", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Application", "type": "BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus", "member": "LastCompletedCycle", "surface": "admin-output", "retentionPolicy": "transient-response" }, + { "assembly": "BunkFy.Modules.Workspaces.Persistence", "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffIdentityAnchorSweepCheckpointDataRightsExport", "member": "Checkpoint", "surface": "data-rights-export", "retentionPolicy": "workspaces-data-rights-export-fragment" } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.receipt-id", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.receipt-id", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "admin-output", + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "Id", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult", + "member": "ReceiptId", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "ReceiptId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.operation-id", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.operation-id", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "admin-output", + "application-command", + "application-query", + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "OperationId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand", + "member": "OperationId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult", + "member": "OperationId", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Ports.IWorkspaceStaffHistoricalNoProvisionReceiptRepository", + "member": "FindByOperationIdAsync.operationId", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "OperationId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.application-id", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.application-id", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "admin-output", + "application-command", + "application-query", + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "ApplicationId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand", + "member": "ApplicationId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult", + "member": "ApplicationId", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Ports.IWorkspaceStaffHistoricalNoProvisionReceiptRepository", + "member": "FindByApplicationIdAsync.applicationId", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "ApplicationId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.scope-id", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.scope-id", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "ScopeId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "ScopeId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.source-kind", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.source-kind", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "SourceKind", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "SourceKind", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.source-id", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.source-id", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "SourceId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "SourceId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.expected-application-version", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.expected-application-version", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "application-command", + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "ExpectedApplicationVersion", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand", + "member": "ExpectedApplicationVersion", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "ExpectedApplicationVersion", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.expected-application-status", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.expected-application-status", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "application-command", + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "ExpectedApplicationStatus", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand", + "member": "ExpectedApplicationStatus", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "ExpectedApplicationStatus", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.result-application-version", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.result-application-version", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "admin-output", + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "ResultApplicationVersion", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult", + "member": "ResultApplicationVersion", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "ResultApplicationVersion", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.result-application-status", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.result-application-status", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "admin-output", + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "ResultApplicationStatus", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult", + "member": "ResultApplicationStatus", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "ResultApplicationStatus", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.organizations-scope-revision", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.organizations-scope-revision", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "application-command", + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "OrganizationsScopeRevision", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand", + "member": "ExpectedOrganizationsScopeRevision", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "OrganizationsScopeRevision", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.organizations-source-version", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.organizations-source-version", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "application-command", + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "OrganizationsSourceVersion", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand", + "member": "ExpectedOrganizationsSourceVersion", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "OrganizationsSourceVersion", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.organizations-source-status", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.organizations-source-status", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "application-command", + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "OrganizationsSourceStatus", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand", + "member": "ExpectedOrganizationsSourceStatus", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "OrganizationsSourceStatus", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.staff-evidence-digest", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.staff-evidence-digest", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "admin-output", + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "StaffEvidenceSha256", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult", + "member": "StaffEvidenceSha256", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "StaffEvidenceSha256", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.external-evidence-manifest-id", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.external-evidence-manifest-id", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "application-command", + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "ExternalEvidenceManifestId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand", + "member": "ExternalEvidenceManifestId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "ExternalEvidenceManifestId", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.external-evidence-digest", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.external-evidence-digest", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "application-command", + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "ExternalEvidenceSha256", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand", + "member": "ExternalEvidenceSha256", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "ExternalEvidenceSha256", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.reviewed-at", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.reviewed-at", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "admin-output", + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "ReviewedAtUtc", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult", + "member": "ReviewedAtUtc", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "ReviewedAtUtc", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.canonical-digest", + "dataSubject": "staff", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.canonical-digest", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "admin-output", + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "CanonicalSha256", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult", + "member": "CanonicalSha256", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "CanonicalSha256", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.contract-version", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.contract-version", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "data-rights-export", + "persistence" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Domain", + "type": "BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt", + "member": "ContractVersion", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Workspaces.Persistence", + "type": "BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport", + "member": "ContractVersion", + "surface": "data-rights-export", + "retentionPolicy": "workspaces-data-rights-export-fragment" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.replay-disposition", + "dataSubject": "staff", + "classification": "lifecycle", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.replay-disposition", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "admin-output" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult", + "member": "AlreadyReviewed", + "surface": "admin-output", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "workspaces.identity-anchor-historical-no-provision.structured-control", + "dataSubject": "staff", + "classification": "structured-payload", + "sensitivity": "elevated", + "purposes": [ + "identity-anchor-historical-no-provision-review", + "identity-anchor-reconciliation", + "tenant-termination-audit" + ], + "sources": [ + "organizations", + "staff", + "workspaces-domain" + ], + "authoritativeOwner": "workspaces", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "workspaces-identity-anchor-historical-no-provision", + "countryPolicyKey": "workspaces.identity-anchor-historical-no-provision.structured-control", + "retentionPolicy": "workspace-historical-no-provision-proof", + "rightsPolicy": "workspace-historical-no-provision-proof-control", + "allowedSurfaces": [ + "application-command" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Workspaces.Application", + "type": "BunkFy.Modules.Workspaces.Application.Ports.IWorkspaceStaffHistoricalNoProvisionReceiptRepository", + "member": "AddAsync.receipt", + "surface": "application-command", + "retentionPolicy": "transient-request" + } + ] } ] } diff --git a/src/Modules/Workspaces/docs/personal-data-inventory.v1.md b/src/Modules/Workspaces/docs/personal-data-inventory.v1.md index f9b65ac1..90681e48 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 v15 Generated from `workspaces.personal-data` schema v1. Catalogue approval: `engineering-default`. @@ -9,11 +9,13 @@ Engineering metadata is not legal or country-launch approval. | Id | Scope | Readers | Writers | |---|---|---|---| -| workspaces-access-history | tenant-internal-access-history | permission:access-control.profiles.read
permission:staff.sensitive-profile.read
system:authorized-audit-consumer | system:workspaces-access-coordinator | +| workspaces-access-history | tenant-internal-access-history | permission:access-control.profiles.read
permission:staff.sensitive-profile.read
system:authorized-audit-consumer
system:workspaces-identity-anchor-cutover | system:workspaces-access-coordinator | | workspaces-audit | tenant-internal-audit | permission:staff.sensitive-profile.read
system:authorized-audit-consumer | system:workspaces-domain | | workspaces-data-rights-anonymisation | tenant-approved-staff-correlation-anonymisation | permission:data-rights.execute
system:workspaces-data-rights | permission:data-rights.execute
system:workspaces-data-rights | | workspaces-data-rights-correction | tenant-staff-data-rights-correction | permission:data-rights.execute
system:workspaces-data-rights | permission:data-rights.execute
system:workspaces-data-rights | | workspaces-event-input | tenant-internal-messaging | system:workspaces-event-handler | system:authorized-module-outbox | +| workspaces-identity-anchor-cutover | tenant-identity-anchor-cutover-evidence | permission:workspaces.identity-anchors.reconcile
system:authorized-audit-consumer
system:workspaces-identity-anchor-cutover | permission:workspaces.identity-anchors.reconcile
system:workspaces-identity-anchor-cutover | +| workspaces-identity-anchor-historical-no-provision | tenant-reviewed-historical-no-provision-proof | permission:workspaces.identity-anchors.historical-no-provision.review
system:authorized-audit-consumer
system:workspaces-identity-anchor-cutover | permission:workspaces.identity-anchors.historical-no-provision.review
system:workspaces-identity-anchor-cutover | | workspaces-internal | tenant-internal | system:workspaces-runtime | system:workspaces-runtime | | workspaces-join-source | tenant-staff-join-source-management | permission:workspaces.staff-onboarding.manage
system:workspaces-onboarding | permission:workspaces.staff-onboarding.manage
system:workspaces-onboarding | | workspaces-onboarding | tenant-onboarding-self-or-manager | permission:data-rights.execute
permission:workspaces.staff-onboarding.manage
subject:self
system:workspaces-onboarding | permission:data-rights.execute
permission:workspaces.staff-onboarding.manage
subject:self
system:workspaces-onboarding | @@ -33,6 +35,8 @@ 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-historical-no-provision-proof | engineering-default | workspace-historical-no-provision-review-completed | approved-audit-retention-expired-or-tenant-termination | pause-approved-disposal | | 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 | @@ -50,10 +54,12 @@ Engineering metadata is not legal or country-launch approval. | account-correlation | include-in-authorized-staff-or-tenant-export | correct-authoritative-auth-or-membership-source | retain-minimum-required-access-correlation | pseudonymize-after-approved-employment-retention | | audit-attribution | include-in-authorized-audit-or-tenant-export | append-corrective-audit-action | retain-minimum-required-audit-attribution | pseudonymize-subject-to-minimum-audit-receipt | | data-rights-correction-accountability | include-in-authorized-audit-or-tenant-export | append-corrective-proof-only | retain-minimum-owner-proof | retain-minimum-approved-accountability-proof | +| identity-anchor-cutover-evidence-control | include-in-authorized-tenant-cutover-evidence | regenerate-from-authoritative-source-evidence | restrict-to-authorized-cutover-operators | discard-on-cutover-request-completion | | onboarding-editable | include-in-authorized-staff-or-tenant-export | replace-before-provisioning-or-correct-in-staff | suppress-non-required-onboarding-use | redact-on-terminal-state-and-delete-after-approved-retention | | staff-employment-history | include-in-authorized-staff-or-tenant-export | append-corrective-employment-action | suppress-non-required-operational-use | anonymize-or-delete-after-approved-employment-retention | | transient-request-data | not-retained-after-request | replace-before-submission | discard-request | discard-on-request-completion | | workspace-data-rights-anonymisation-proof-control | include-in-authorized-staff-data-rights-proof | append-corrective-proof-only | retain-minimum-owner-proof | retain-minimum-pseudonymous-accountability-proof | +| workspace-historical-no-provision-proof-control | include-in-authorized-audit-or-tenant-export | append-corrective-proof-only | retain-minimum-owner-proof | retain-minimum-approved-accountability-proof | | workspace-onboarding-processing-restriction-control | include-in-authorized-staff-or-tenant-export | change-only-through-approved-restriction-workflow | enforce-effective-processing-restriction | remove-with-approved-subject-disposition | | workspace-tenant-destruction-proof-control | include-in-authorized-tenant-termination-audit | append-a-corrective-control-plane-record | retain-only-the-minimum-owner-proof | remove-after-approved-proof-retention | | workspace-tenant-termination-proof-control | include-in-authorized-tenant-termination-audit | append-corrective-proof-only | retain-minimum-owner-proof | retain-minimum-approved-accountability-proof | @@ -72,6 +78,7 @@ Engineering metadata is not legal or country-launch approval. | workspaces.access-process-id | staff | pseudonymous-identifier | standard | access-process-correlation
retry-safety | system-generated | workspaces | customer-controller-bunk-fy-processor | workspaces-access-history | workspaces.access-process-id | workspace-access-history | staff-employment-history | admin-output
application-command
data-rights-export
persistence
projection-export | cross-module
intra-module
support | engineering-default | | workspaces.access-process-last-changed-at | staff | lifecycle | standard | access-process-history
recovery | system-clock | workspaces | customer-controller-bunk-fy-processor | workspaces-access-history | workspaces.access-process-last-changed-at | workspace-access-history | staff-employment-history | admin-output
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | | workspaces.access-process-profile-count | staff | linked-operational | standard | access-review
assignment-summary | workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-access-history | workspaces.access-process-profile-count | workspace-access-history | staff-employment-history | admin-output | support | engineering-default | +| workspaces.access-process-restoration-disposition | staff | lifecycle | elevated | access-restoration
access-restoration-suppression | workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-access-history | workspaces.access-process-restoration-disposition | workspace-access-history | staff-employment-history | admin-output
data-rights-export
persistence
projection-export | cross-module
intra-module | engineering-default | | workspaces.access-process-state | staff | lifecycle | standard | access-process-lifecycle
recovery | workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-access-history | workspaces.access-process-state | workspace-access-history | staff-employment-history | admin-output
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | | workspaces.access-process-version | staff | lifecycle | standard | access-process-history
optimistic-concurrency | workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-access-history | workspaces.access-process-version | workspace-access-history | staff-employment-history | admin-output
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | | workspaces.access-profile-display-name | staff | linked-operational | standard | access-review
assignment-display | access-control | access-control | customer-controller-bunk-fy-processor | workspaces-access-history | workspaces.access-profile-display-name | transient-response | staff-employment-history | api-response | customer-api | engineering-default | @@ -81,7 +88,7 @@ Engineering metadata is not legal or country-launch approval. | workspaces.access-target-state | staff | lifecycle | standard | deny-or-restore-access
employment-access-coordination | staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-access-history | workspaces.access-target-state | workspace-access-history | staff-employment-history | admin-output
data-rights-export
persistence
projection-export | cross-module
intra-module
support | engineering-default | | workspaces.actor-subject-id | staff | audit-attribution | elevated | access-change-attribution
security-audit | auth
staff | auth | customer-controller-bunk-fy-processor | workspaces-audit | workspaces.actor-subject-id | workspace-access-history | audit-attribution | application-command
data-rights-export
integration-event
persistence | cross-module
intra-module | engineering-default | | workspaces.assignment-scope | staff | linked-operational | standard | access-profile-assignment
scope-resolution | access-control | access-control | customer-controller-bunk-fy-processor | workspaces-access-history | workspaces.assignment-scope | workspace-access-history | staff-employment-history | data-rights-export
persistence | cross-module
intra-module | engineering-default | -| workspaces.auth-subject-id | staff | pseudonymous-identifier | elevated | account-correlation
membership-linkage
onboarding-self-service | auth
organizations
staff | auth | customer-controller-bunk-fy-processor | workspaces-sensitive | workspaces.auth-subject-id | workspace-access-history | account-correlation | api-response
application-command
application-query
data-rights-export
integration-event
persistence | cross-module
customer-api
intra-module | engineering-default | +| workspaces.auth-subject-id | staff | pseudonymous-identifier | elevated | account-correlation
membership-linkage
onboarding-self-service | auth
organizations
staff | auth | customer-controller-bunk-fy-processor | workspaces-sensitive | workspaces.auth-subject-id | workspace-access-history | account-correlation | api-response
application-command
application-query
data-rights-export
integration-event
persistence
projection-export | cross-module
customer-api
intra-module | engineering-default | | workspaces.bounded-failure-code | staff | lifecycle | standard | operator-triage
recoverable-failure-state | workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-access-history | workspaces.bounded-failure-code | workspace-access-history | staff-employment-history | admin-output
api-response
data-rights-export
persistence | cross-module
customer-api
intra-module
support | engineering-default | | workspaces.data-rights.actor-id | staff | audit-attribution | standard | approved-operation-binding
authorized-change-traceability | authenticated-subject | workspaces | customer-controller-bunk-fy-processor | workspaces-data-rights-correction | workspaces.data-rights.actor-id | transient-request | audit-attribution | application-command
application-query | intra-module | engineering-default | | workspaces.data-rights.applicant-event-id | staff | linked-operational | standard | correction-accountability
owner-mutation-proof | workspaces-id-generator | workspaces | customer-controller-bunk-fy-processor | workspaces-data-rights-correction | workspaces.data-rights.applicant-event-id | workspace-data-rights-correction-proof | data-rights-correction-accountability | data-rights-export
persistence | cross-module
intra-module | engineering-default | @@ -102,8 +109,46 @@ 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.identity-anchor-cutover.historical-evidence-digest | staff | pseudonymous-identifier | elevated | cutover-completeness-evidence
historical-owner-universe-review | operator-reviewed-historical-owner-evidence | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-cutover | workspaces.identity-anchor-cutover-evidence | transient-response | identity-anchor-cutover-evidence-control | admin-output
application-command
application-query | intra-module
support | engineering-default | +| workspaces.identity-anchor-cutover.owner-manifest-digest | staff | pseudonymous-identifier | elevated | reviewed-owner-binding-evidence
stale-decision-prevention | operator-reviewed-organizations-export | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-cutover | workspaces.identity-anchor-cutover-evidence | transient-response | identity-anchor-cutover-evidence-control | admin-output
application-command | intra-module
support | engineering-default | +| workspaces.identity-anchor-cutover.source-evidence-digest | staff | pseudonymous-identifier | elevated | authoritative-source-evidence
stale-decision-prevention | organizations-scope-export
workspaces-onboarding | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-cutover | workspaces.identity-anchor-cutover-evidence | transient-response | identity-anchor-cutover-evidence-control | admin-output
application-command | intra-module
support | engineering-default | +| workspaces.identity-anchor-cutover.state-digest | staff | pseudonymous-identifier | elevated | anchor-state-review
cutover-conflict-evidence | staff-identity-anchor-inspection | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-cutover | workspaces.identity-anchor-cutover-evidence | transient-response | identity-anchor-cutover-evidence-control | admin-output
application-command | intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.application-id | staff | pseudonymous-identifier | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.application-id | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | admin-output
application-command
application-query
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.canonical-digest | staff | pseudonymous-identifier | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.canonical-digest | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | admin-output
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.contract-version | staff | lifecycle | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.contract-version | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.expected-application-status | staff | lifecycle | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.expected-application-status | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | application-command
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.expected-application-version | staff | lifecycle | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.expected-application-version | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | application-command
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.external-evidence-digest | staff | pseudonymous-identifier | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.external-evidence-digest | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | application-command
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.external-evidence-manifest-id | staff | pseudonymous-identifier | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.external-evidence-manifest-id | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | application-command
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.operation-id | staff | pseudonymous-identifier | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.operation-id | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | admin-output
application-command
application-query
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.organizations-scope-revision | staff | lifecycle | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.organizations-scope-revision | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | application-command
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.organizations-source-status | staff | lifecycle | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.organizations-source-status | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | application-command
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.organizations-source-version | staff | lifecycle | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.organizations-source-version | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | application-command
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.receipt-id | staff | pseudonymous-identifier | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.receipt-id | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | admin-output
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.replay-disposition | staff | lifecycle | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.replay-disposition | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | admin-output | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.result-application-status | staff | lifecycle | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.result-application-status | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | admin-output
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.result-application-version | staff | lifecycle | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.result-application-version | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | admin-output
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.reviewed-at | staff | lifecycle | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.reviewed-at | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | admin-output
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.scope-id | staff | pseudonymous-identifier | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.scope-id | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.source-id | staff | pseudonymous-identifier | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.source-id | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.source-kind | staff | lifecycle | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.source-kind | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.staff-evidence-digest | staff | pseudonymous-identifier | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.staff-evidence-digest | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | admin-output
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-historical-no-provision.structured-control | staff | structured-payload | elevated | identity-anchor-historical-no-provision-review
identity-anchor-reconciliation
tenant-termination-audit | organizations
staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-identity-anchor-historical-no-provision | workspaces.identity-anchor-historical-no-provision.structured-control | workspace-historical-no-provision-proof | workspace-historical-no-provision-proof-control | application-command | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-sweep.coordinate | staff | pseudonymous-identifier | elevated | identity-anchor-reconciliation
message-idempotency | staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.identity-anchor-sweep.coordinate | workspace-onboarding-lifecycle | staff-employment-history | admin-output
application-command
domain-event
integration-event
persistence
projection-export | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-sweep.lifecycle | staff | lifecycle | elevated | identity-anchor-reconciliation
identity-anchor-resolution | staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.identity-anchor-sweep.lifecycle | workspace-onboarding-lifecycle | staff-employment-history | admin-output
application-command
domain-event
integration-event
persistence
projection-export | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor-sweep.operational-control | staff | linked-operational | standard | bounded-backlog-observation
identity-anchor-reconciliation | workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.identity-anchor-sweep.operational-control | workspace-onboarding-lifecycle | staff-employment-history | admin-output
application-command
persistence
projection-export | intra-module
support | engineering-default | +| workspaces.identity-anchor-sweep.structured-control | staff | structured-payload | elevated | identity-anchor-reconciliation | staff
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.identity-anchor-sweep.structured-control | workspace-onboarding-lifecycle | staff-employment-history | admin-output
application-command
data-rights-export
projection-export | cross-module
intra-module
support | engineering-default | +| workspaces.identity-anchor.continuation-event-id | staff | pseudonymous-identifier | elevated | identity-anchor-continuation
message-idempotency | workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.identity-anchor.continuation-event-id | workspace-onboarding-lifecycle | staff-employment-history | data-rights-export
persistence | cross-module
intra-module | engineering-default | +| workspaces.identity-anchor.expected-resolution-event-id | staff | pseudonymous-identifier | elevated | identity-anchor-resolution
message-idempotency | staff | staff | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.identity-anchor.expected-resolution-event-id | workspace-onboarding-lifecycle | staff-employment-history | data-rights-export
persistence | cross-module
intra-module | engineering-default | +| workspaces.identity-anchor.resolution-application-version | staff | lifecycle | standard | exact-replay
identity-anchor-resolution | workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.identity-anchor.resolution-application-version | workspace-onboarding-lifecycle | staff-employment-history | data-rights-export
persistence | cross-module
intra-module | engineering-default | +| workspaces.identity-anchor.resolution-disposition | staff | lifecycle | elevated | access-restoration-suppression
identity-anchor-resolution | workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.identity-anchor.resolution-disposition | workspace-onboarding-lifecycle | staff-employment-history | data-rights-export
persistence | cross-module
intra-module | engineering-default | +| workspaces.identity-anchor.resolution-event-id | staff | pseudonymous-identifier | elevated | identity-anchor-resolution
message-idempotency | staff | staff | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.identity-anchor.resolution-event-id | workspace-onboarding-lifecycle | staff-employment-history | data-rights-export
persistence | cross-module
intra-module | engineering-default | +| workspaces.identity-anchor.resolution-intent-at-utc | staff | lifecycle | standard | audit-correlation
identity-anchor-resolution | system-clock | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.identity-anchor.resolution-intent-at-utc | workspace-onboarding-lifecycle | staff-employment-history | data-rights-export
persistence | cross-module
intra-module | engineering-default | +| workspaces.identity-anchor.resolution-observed-at-utc | staff | lifecycle | standard | identity-anchor-resolution
reconciliation | system-clock | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.identity-anchor.resolution-observed-at-utc | workspace-onboarding-lifecycle | staff-employment-history | data-rights-export
persistence | cross-module
intra-module | engineering-default | +| workspaces.identity-anchor.resolution-staff-member-id | staff | pseudonymous-identifier | elevated | identity-anchor-resolution
staff-linkage | staff | staff | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.identity-anchor.resolution-staff-member-id | workspace-onboarding-lifecycle | staff-employment-history | data-rights-export
persistence | cross-module
intra-module | engineering-default | +| workspaces.identity-anchor.sweep-ordinal | staff | linked-operational | standard | bounded-keyset-paging
identity-anchor-reconciliation | database-sequence | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.identity-anchor.sweep-ordinal | workspace-onboarding-lifecycle | staff-employment-history | data-rights-export
persistence | cross-module
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 | 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 | @@ -122,7 +167,7 @@ Engineering metadata is not legal or country-launch approval. | workspaces.member-access-assignments | staff | structured-payload | elevated | member-access-review | access-control | access-control | customer-controller-bunk-fy-processor | workspaces-access-history | workspaces.member-access-assignments | transient-response | staff-employment-history | api-response | customer-api | engineering-default | | workspaces.membership-id | staff | pseudonymous-identifier | standard | access-bootstrap
membership-linkage | organizations | organizations | customer-controller-bunk-fy-processor | workspaces-event-input | workspaces.membership-id | integration-message-journal | staff-employment-history | integration-event | cross-module | engineering-default | | workspaces.membership-lifecycle | staff | lifecycle | standard | access-bootstrap
membership-access-coordination | organizations | organizations | customer-controller-bunk-fy-processor | workspaces-event-input | workspaces.membership-lifecycle | integration-message-journal | staff-employment-history | integration-event | cross-module | engineering-default | -| workspaces.onboarding-application-id | staff | pseudonymous-identifier | standard | onboarding-correlation
retry-safety | system-generated | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.onboarding-application-id | workspace-onboarding-lifecycle | staff-employment-history | api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| workspaces.onboarding-application-id | staff | pseudonymous-identifier | standard | onboarding-correlation
retry-safety | system-generated | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.onboarding-application-id | workspace-onboarding-lifecycle | staff-employment-history | api-response
application-command
data-rights-export
persistence
projection-export | cross-module
customer-api
intra-module | engineering-default | | workspaces.onboarding-created-at | staff | lifecycle | standard | onboarding-history
retention-start | system-clock | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.onboarding-created-at | workspace-onboarding-lifecycle | staff-employment-history | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | workspaces.onboarding-last-changed-at | staff | lifecycle | standard | onboarding-history
recovery | system-clock | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.onboarding-last-changed-at | workspace-onboarding-lifecycle | staff-employment-history | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | workspaces.onboarding-restriction.action | staff | linked-operational | standard | restriction-accountability
transition-disambiguation | approved-data-rights-case | workspaces | customer-controller-bunk-fy-processor | workspaces-processing-restrictions | workspaces.staff-onboarding.processing-restriction-state | workspace-onboarding-processing-restriction-receipt | workspace-onboarding-processing-restriction-control | admin-output
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | @@ -148,7 +193,7 @@ Engineering metadata is not legal or country-launch approval. | workspaces.onboarding-restriction.selected-onboarding-version | staff | linked-operational | standard | approved-operation-binding
stale-decision-prevention | approved-data-rights-case | workspaces | customer-controller-bunk-fy-processor | workspaces-processing-restrictions | workspaces.staff-onboarding.processing-restriction-state | workspace-onboarding-processing-restriction-record | workspace-onboarding-processing-restriction-control | admin-output
application-command
data-rights-export
persistence | cross-module
intra-module
support | engineering-default | | workspaces.onboarding-restriction.status | staff | linked-operational | standard | effective-restriction
restriction-lifecycle | workspace-onboarding-restriction-workflow | workspaces | customer-controller-bunk-fy-processor | workspaces-processing-restrictions | workspaces.staff-onboarding.processing-restriction-state | workspace-onboarding-processing-restriction-record | workspace-onboarding-processing-restriction-control | data-rights-export
persistence | cross-module
intra-module | engineering-default | | workspaces.onboarding-restriction.tenant-scope-id | staff | linked-operational | standard | effective-restriction
tenant-isolation | authenticated-tenant-context | workspaces | customer-controller-bunk-fy-processor | workspaces-processing-restrictions | workspaces.staff-onboarding.processing-restriction-state | workspace-onboarding-processing-restriction-lifecycle | workspace-onboarding-processing-restriction-control | domain-event
integration-event
persistence | cross-module
intra-module | engineering-default | -| workspaces.onboarding-status | staff | lifecycle | standard | onboarding-lifecycle
recovery | workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.onboarding-status | workspace-onboarding-lifecycle | staff-employment-history | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| workspaces.onboarding-status | staff | lifecycle | standard | onboarding-lifecycle
recovery | workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.onboarding-status | workspace-onboarding-lifecycle | staff-employment-history | api-response
data-rights-export
persistence
projection-export | cross-module
customer-api
intra-module | engineering-default | | workspaces.onboarding-version | staff | lifecycle | standard | onboarding-history
optimistic-concurrency | workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-onboarding | workspaces.onboarding-version | workspace-onboarding-lifecycle | staff-employment-history | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | workspaces.property-assignment-id | staff | linked-operational | standard | access-plan
property-access-assignment | operator-selection
properties | properties | customer-controller-bunk-fy-processor | workspaces-access-history | workspaces.property-assignment-id | workspace-access-history | staff-employment-history | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | workspaces.proposed-department | staff | linked-operational | standard | employment-administration
staff-profile-proposal | staff-self-service | workspaces | customer-controller-bunk-fy-processor | workspaces-sensitive | workspaces.proposed-department | workspace-onboarding-applicant-copy | onboarding-editable | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | @@ -158,7 +203,7 @@ Engineering metadata is not legal or country-launch approval. | workspaces.proposed-legal-name | staff | direct-identifier | elevated | onboarding-review
staff-profile-proposal | staff-self-service | workspaces | customer-controller-bunk-fy-processor | workspaces-sensitive | workspaces.proposed-legal-name | workspace-onboarding-applicant-copy | onboarding-editable | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | workspaces.proposed-work-email | staff | contact | elevated | staff-profile-proposal
work-contact | staff-self-service | workspaces | customer-controller-bunk-fy-processor | workspaces-sensitive | workspaces.proposed-work-email | workspace-onboarding-applicant-copy | onboarding-editable | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | workspaces.proposed-work-phone | staff | contact | elevated | staff-profile-proposal
work-contact | staff-self-service | workspaces | customer-controller-bunk-fy-processor | workspaces-sensitive | workspaces.proposed-work-phone | workspace-onboarding-applicant-copy | onboarding-editable | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | -| workspaces.response-items | staff | structured-payload | elevated | authorized-listing
operator-review | workspaces-query | workspaces | customer-controller-bunk-fy-processor | workspaces-sensitive | workspaces.response-items | transient-response | transient-request-data | admin-output
api-response | customer-api
support | engineering-default | +| workspaces.response-items | staff | structured-payload | elevated | authorized-listing
operator-review | workspaces-query | workspaces | customer-controller-bunk-fy-processor | workspaces-sensitive | workspaces.response-items | transient-response | transient-request-data | admin-output
api-response
projection-export | customer-api
intra-module
support | engineering-default | | workspaces.retention-correlation-proof | staff | linked-operational | elevated | idempotent-replay
immutable-owner-proof
retention-correlation-scrub | staff-retention-execution
system-clock
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-audit | workspaces.retention-correlation-proof | workspace-retention-proof | staff-employment-history | application-command
data-rights-export
persistence | cross-module
intra-module | engineering-default | | workspaces.staff-correlation-anonymisation.access-plan-record-count | staff | linked-operational | standard | bounded-mutation-accountability
restore-replay-verification | workspaces-owner-mutation | workspaces | customer-controller-bunk-fy-processor | workspaces-data-rights-anonymisation | workspaces.staff-correlation.anonymisation | workspace-data-rights-anonymisation-proof | workspace-data-rights-anonymisation-proof-control | persistence
projection-export | cross-module
intra-module | engineering-default | | workspaces.staff-correlation-anonymisation.access-process-record-count | staff | linked-operational | standard | bounded-mutation-accountability
restore-replay-verification | workspaces-owner-mutation | workspaces | customer-controller-bunk-fy-processor | workspaces-data-rights-anonymisation | workspaces.staff-correlation.anonymisation | workspace-data-rights-anonymisation-proof | workspace-data-rights-anonymisation-proof-control | persistence
projection-export | cross-module
intra-module | engineering-default | @@ -195,13 +240,13 @@ Engineering metadata is not legal or country-launch approval. | workspaces.staff-correlation-anonymisation.tombstone-revision | staff | lifecycle | standard | proof-versioning
restore-concurrency | workspaces-anonymisation-tombstone | workspaces | customer-controller-bunk-fy-processor | workspaces-data-rights-anonymisation | workspaces.staff-correlation.anonymisation | workspace-data-rights-anonymisation-proof | workspace-data-rights-anonymisation-proof-control | persistence | intra-module | engineering-default | | workspaces.staff-lifecycle-context | staff | structured-payload | elevated | employment-access-coordination | staff | staff | customer-controller-bunk-fy-processor | workspaces-internal | workspaces.staff-lifecycle-context | transient-request | transient-request-data | application-command | intra-module | engineering-default | | workspaces.staff-lifecycle-status | staff | lifecycle | standard | employment-access-coordination | staff | staff | customer-controller-bunk-fy-processor | workspaces-event-input | workspaces.staff-lifecycle-status | integration-message-journal | staff-employment-history | integration-event | cross-module | engineering-default | -| workspaces.staff-member-id | staff | pseudonymous-identifier | standard | access-coordination
staff-linkage | staff
workspaces-domain | staff | customer-controller-bunk-fy-processor | workspaces-access-history | workspaces.staff-member-id | workspace-access-history | staff-employment-history | admin-output
api-response
application-command
data-rights-export
integration-event
persistence | cross-module
customer-api
intra-module
support | engineering-default | +| workspaces.staff-member-id | staff | pseudonymous-identifier | standard | access-coordination
staff-linkage | staff
workspaces-domain | staff | customer-controller-bunk-fy-processor | workspaces-access-history | workspaces.staff-member-id | workspace-access-history | staff-employment-history | admin-output
api-response
application-command
data-rights-export
integration-event
persistence
projection-export | cross-module
customer-api
intra-module
support | engineering-default | | workspaces.target-staff-version | staff | lifecycle | standard | cross-module-concurrency
employment-access-coordination | staff | staff | customer-controller-bunk-fy-processor | workspaces-access-history | workspaces.target-staff-version | workspace-access-history | staff-employment-history | admin-output
application-command
data-rights-export
integration-event
persistence | cross-module
intra-module
support | engineering-default | | workspaces.tenant-destruction.owner-proof | subject-scoped | linked-operational | elevated | destruction-accountability
owner-proof-correlation
tenant-termination-safety | approved-tenant-termination-case
workspaces-owner-destruction | workspaces | customer-controller-bunk-fy-processor | workspaces-tenant-destruction-proof | workspaces.tenant-destruction.owner-proof | workspace-tenant-destruction-proof | workspace-tenant-destruction-proof-control | persistence | intra-module
processor | engineering-default | | workspaces.tenant-termination-actor-id | staff | audit-attribution | elevated | authorized-change-traceability
tenant-termination-accountability | authorized-data-rights-executor | auth | customer-controller-bunk-fy-processor | workspaces-tenant-termination-proof | workspaces.tenant-termination.actor-id | workspace-tenant-termination-proof | audit-attribution | application-command
persistence
projection-export | cross-module
intra-module | engineering-default | | workspaces.tenant-termination-control-proof | staff | linked-operational | elevated | admission-control
owner-proof-correlation
restore-integrity
tenant-termination-safety | approved-tenant-termination-case
data-rights-coordinator
workspaces-domain | workspaces | customer-controller-bunk-fy-processor | workspaces-tenant-termination-proof | workspaces.tenant-termination.control-proof | workspace-tenant-termination-proof | workspace-tenant-termination-proof-control | application-command
persistence
projection-export | cross-module
intra-module | engineering-default | | workspaces.verified-account-email | staff | contact | elevated | recipient-match
verified-account-admission | auth
organizations | auth | customer-controller-bunk-fy-processor | workspaces-sensitive | workspaces.verified-account-email | workspace-onboarding-applicant-copy | onboarding-editable | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | -| workspaces.workspace-scope-id | staff | linked-operational | standard | tenant-isolation
workspace-linkage | organizations
system-derived | organizations | customer-controller-bunk-fy-processor | workspaces-internal | workspaces.workspace-scope-id | workspace-access-history | staff-employment-history | admin-output
api-response
application-command
data-rights-export
integration-event
persistence | cross-module
customer-api
intra-module
support | engineering-default | +| workspaces.workspace-scope-id | staff | linked-operational | standard | tenant-isolation
workspace-linkage | organizations
system-derived | organizations | customer-controller-bunk-fy-processor | workspaces-internal | workspaces.workspace-scope-id | workspace-access-history | staff-employment-history | admin-output
api-response
application-command
application-query
data-rights-export
integration-event
persistence | cross-module
customer-api
intra-module
support | engineering-default | ## Code Bindings @@ -240,6 +285,10 @@ Engineering metadata is not legal or country-launch approval. | workspaces.access-process-last-changed-at | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessProcess | LastChangedAtUtc | persistence | workspace-access-history | | workspaces.access-process-last-changed-at | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffAccessProcessDataRightsExport | LastChangedAtUtc | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.access-process-profile-count | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffAccessProcessDto | ProfileCount | admin-output | transient-response | +| workspaces.access-process-restoration-disposition | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.WorkspaceStaffAccessPreparation | RestorationDisposition | projection-export | transient-response | +| workspaces.access-process-restoration-disposition | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffAccessProcessDto | RestorationDisposition | admin-output | transient-response | +| workspaces.access-process-restoration-disposition | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessProcess | RestorationDisposition | persistence | workspace-access-history | +| workspaces.access-process-restoration-disposition | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffAccessProcessDataRightsExport | RestorationDisposition | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.access-process-state | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffAccessProcessDto | Status | admin-output | transient-response | | workspaces.access-process-state | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessProcess | State | persistence | workspace-access-history | | workspaces.access-process-state | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffAccessProcessDataRightsExport | State | data-rights-export | workspaces-data-rights-export-fragment | @@ -271,15 +320,20 @@ Engineering metadata is not legal or country-launch approval. | workspaces.access-target-state | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffAccessProcessDataRightsExport | TargetState | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.actor-subject-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffMemberLifecycleChangedIntegrationEvent | ActorId | integration-event | integration-message-journal | | workspaces.actor-subject-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.PrepareWorkspaceStaffAccessPlanCommand | ActorSubjectId | application-command | transient-request | +| workspaces.actor-subject-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand | ReviewerId | application-command | transient-request | | workspaces.actor-subject-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceEnrollmentLinkIssuanceRequest | ActorSubjectId | application-command | transient-request | | workspaces.actor-subject-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceInvitationIssuanceRequest | ActorSubjectId | application-command | transient-request | | workspaces.actor-subject-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessPlan | CreatedBySubjectId | persistence | workspace-access-history | | workspaces.actor-subject-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessProcess | RequestedBy | persistence | workspace-access-history | +| workspaces.actor-subject-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | ReviewerId | persistence | workspace-access-history | | workspaces.actor-subject-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffAccessPlanDataRightsExport | CreatedBySubjectId | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.actor-subject-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffAccessProcessDataRightsExport | RequestedBy | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.actor-subject-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | ReviewerId | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.assignment-scope | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessProfileSnapshot | AssignmentScope | persistence | workspace-access-history | | workspaces.assignment-scope | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffAccessProfileDataRightsExport | AssignmentScope | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.auth-subject-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.SubmitWorkspaceStaffOnboardingCommand | SubjectId | application-command | transient-request | +| workspaces.auth-subject-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Ports.IWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence | CanMutateAsync.subjectId | application-query | transient-request | +| workspaces.auth-subject-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Ports.WorkspaceStaffIdentityAnchorSourceRecord | SubjectId | projection-export | transient-response | | workspaces.auth-subject-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Ports.WorkspaceStaffRetentionCorrelationScrubRequest | SubjectId | application-command | transient-request | | workspaces.auth-subject-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Queries.GetOwnWorkspaceStaffOnboardingQuery | SubjectId | application-query | transient-request | | workspaces.auth-subject-id | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceMemberAccessDto | SubjectId | api-response | transient-response | @@ -364,7 +418,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 +428,235 @@ 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.identity-anchor-cutover.historical-evidence-digest | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceStaffIdentityAnchorCutoverStatus | HistoricalEvidenceSha256 | admin-output | transient-response | +| workspaces.identity-anchor-cutover.historical-evidence-digest | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceStaffIdentityAnchorHistoricalEvidence | EvidenceSha256 | application-command | transient-request | +| workspaces.identity-anchor-cutover.historical-evidence-digest | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceStaffIdentityAnchorHistoricalEvidence | EvidenceSha256 | application-query | transient-request | +| workspaces.identity-anchor-cutover.owner-manifest-digest | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ReconcileWorkspaceStaffIdentityAnchorsCommand | ExpectedOwnerManifestSha256 | application-command | transient-request | +| workspaces.identity-anchor-cutover.owner-manifest-digest | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceStaffIdentityAnchorCutoverStatus | OwnerManifestSha256 | admin-output | transient-response | +| workspaces.identity-anchor-cutover.source-evidence-digest | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ReconcileWorkspaceStaffIdentityAnchorsCommand | ExpectedSourceEvidenceSha256 | application-command | transient-request | +| workspaces.identity-anchor-cutover.source-evidence-digest | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceStaffIdentityAnchorCutoverStatus | SourceEvidenceSha256 | admin-output | transient-response | +| workspaces.identity-anchor-cutover.state-digest | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ReconcileWorkspaceStaffIdentityAnchorsCommand | ExpectedAnchorStateSha256 | application-command | transient-request | +| workspaces.identity-anchor-cutover.state-digest | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceStaffIdentityAnchorCutoverStatus | AnchorStateSha256 | admin-output | transient-response | +| workspaces.identity-anchor-historical-no-provision.application-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand | ApplicationId | application-command | transient-request | +| workspaces.identity-anchor-historical-no-provision.application-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Ports.IWorkspaceStaffHistoricalNoProvisionReceiptRepository | FindByApplicationIdAsync.applicationId | application-query | transient-request | +| workspaces.identity-anchor-historical-no-provision.application-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult | ApplicationId | admin-output | transient-response | +| workspaces.identity-anchor-historical-no-provision.application-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | ApplicationId | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.application-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | ApplicationId | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.canonical-digest | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult | CanonicalSha256 | admin-output | transient-response | +| workspaces.identity-anchor-historical-no-provision.canonical-digest | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | CanonicalSha256 | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.canonical-digest | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | CanonicalSha256 | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.contract-version | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | ContractVersion | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.contract-version | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | ContractVersion | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.expected-application-status | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand | ExpectedApplicationStatus | application-command | transient-request | +| workspaces.identity-anchor-historical-no-provision.expected-application-status | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | ExpectedApplicationStatus | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.expected-application-status | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | ExpectedApplicationStatus | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.expected-application-version | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand | ExpectedApplicationVersion | application-command | transient-request | +| workspaces.identity-anchor-historical-no-provision.expected-application-version | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | ExpectedApplicationVersion | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.expected-application-version | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | ExpectedApplicationVersion | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.external-evidence-digest | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand | ExternalEvidenceSha256 | application-command | transient-request | +| workspaces.identity-anchor-historical-no-provision.external-evidence-digest | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | ExternalEvidenceSha256 | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.external-evidence-digest | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | ExternalEvidenceSha256 | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.external-evidence-manifest-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand | ExternalEvidenceManifestId | application-command | transient-request | +| workspaces.identity-anchor-historical-no-provision.external-evidence-manifest-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | ExternalEvidenceManifestId | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.external-evidence-manifest-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | ExternalEvidenceManifestId | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.operation-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand | OperationId | application-command | transient-request | +| workspaces.identity-anchor-historical-no-provision.operation-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Ports.IWorkspaceStaffHistoricalNoProvisionReceiptRepository | FindByOperationIdAsync.operationId | application-query | transient-request | +| workspaces.identity-anchor-historical-no-provision.operation-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult | OperationId | admin-output | transient-response | +| workspaces.identity-anchor-historical-no-provision.operation-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | OperationId | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.operation-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | OperationId | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.organizations-scope-revision | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand | ExpectedOrganizationsScopeRevision | application-command | transient-request | +| workspaces.identity-anchor-historical-no-provision.organizations-scope-revision | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | OrganizationsScopeRevision | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.organizations-scope-revision | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | OrganizationsScopeRevision | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.organizations-source-status | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand | ExpectedOrganizationsSourceStatus | application-command | transient-request | +| workspaces.identity-anchor-historical-no-provision.organizations-source-status | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | OrganizationsSourceStatus | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.organizations-source-status | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | OrganizationsSourceStatus | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.organizations-source-version | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ReviewWorkspaceStaffHistoricalNoProvisionCommand | ExpectedOrganizationsSourceVersion | application-command | transient-request | +| workspaces.identity-anchor-historical-no-provision.organizations-source-version | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | OrganizationsSourceVersion | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.organizations-source-version | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | OrganizationsSourceVersion | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.receipt-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult | ReceiptId | admin-output | transient-response | +| workspaces.identity-anchor-historical-no-provision.receipt-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | Id | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.receipt-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | ReceiptId | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.replay-disposition | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult | AlreadyReviewed | admin-output | transient-response | +| workspaces.identity-anchor-historical-no-provision.result-application-status | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult | ResultApplicationStatus | admin-output | transient-response | +| workspaces.identity-anchor-historical-no-provision.result-application-status | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | ResultApplicationStatus | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.result-application-status | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | ResultApplicationStatus | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.result-application-version | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult | ResultApplicationVersion | admin-output | transient-response | +| workspaces.identity-anchor-historical-no-provision.result-application-version | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | ResultApplicationVersion | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.result-application-version | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | ResultApplicationVersion | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.reviewed-at | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult | ReviewedAtUtc | admin-output | transient-response | +| workspaces.identity-anchor-historical-no-provision.reviewed-at | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | ReviewedAtUtc | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.reviewed-at | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | ReviewedAtUtc | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.scope-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | ScopeId | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.scope-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | ScopeId | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.source-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | SourceId | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.source-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | SourceId | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.source-kind | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | SourceKind | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.source-kind | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | SourceKind | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.staff-evidence-digest | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.WorkspaceStaffHistoricalNoProvisionDispositionResult | StaffEvidenceSha256 | admin-output | transient-response | +| workspaces.identity-anchor-historical-no-provision.staff-evidence-digest | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffHistoricalNoProvisionReceipt | StaffEvidenceSha256 | persistence | workspace-historical-no-provision-proof | +| workspaces.identity-anchor-historical-no-provision.staff-evidence-digest | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport | StaffEvidenceSha256 | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor-historical-no-provision.structured-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Ports.IWorkspaceStaffHistoricalNoProvisionReceiptRepository | AddAsync.receipt | application-command | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.PrepareWorkspaceStaffIdentityAnchorSweepPageCommand | CheckpointId | application-command | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.PrepareWorkspaceStaffIdentityAnchorSweepPageCommand | CycleId | application-command | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.PrepareWorkspaceStaffIdentityAnchorSweepPageCommand | EmptyAdvanceId | application-command | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.PrepareWorkspaceStaffIdentityAnchorSweepPageCommand | RunId | application-command | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommand | ApplicationId | application-command | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance | AdvanceId | application-command | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance | CheckpointId | application-command | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance | ExpectedCycleId | application-command | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance | RunId | application-command | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepCandidate | ApplicationId | projection-export | transient-response | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepCandidate | SubjectId | projection-export | transient-response | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepCandidateResult | ApplicationId | projection-export | transient-response | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage | CheckpointId | projection-export | transient-response | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage | CycleId | projection-export | transient-response | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | CycleId | admin-output | transient-response | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | LastCompletedCycleId | admin-output | transient-response | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | LastRunId | admin-output | transient-response | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | ScopeId | admin-output | transient-response | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent | ApplicationId | integration-event | integration-message-journal | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent | EventId | integration-event | integration-message-journal | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent | ScopeId | integration-event | integration-message-journal | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent | StaffMemberId | integration-event | integration-message-journal | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent | ApplicationId | integration-event | integration-message-journal | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent | EventId | integration-event | integration-message-journal | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent | ScopeId | integration-event | integration-message-journal | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent | StaffMemberId | integration-event | integration-message-journal | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent | ApplicationId | domain-event | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent | EventId | domain-event | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent | ScopeId | domain-event | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent | StaffMemberId | domain-event | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent | ApplicationId | domain-event | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent | EventId | domain-event | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent | ScopeId | domain-event | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent | StaffMemberId | domain-event | transient-request | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | CycleId | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | Id | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | LastAdvanceId | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | LastAdvanceSha256 | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | LastCompletedCycleId | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | LastRunId | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.coordinate | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | ScopeId | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance | ReachedEnd | application-command | transient-request | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepCandidate | HasLocalAnchorState | projection-export | transient-response | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepCandidateResult | Outcome | projection-export | transient-response | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage | AdvanceRequired | projection-export | transient-response | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage | ReachedEnd | projection-export | transient-response | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | CycleStartedAtUtc | admin-output | transient-response | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | HasActiveCycle | admin-output | transient-response | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | HasCheckpoint | admin-output | transient-response | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | HasCompletedBoundedCycle | admin-output | transient-response | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | HasCompletedCycleObservation | admin-output | transient-response | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | LastCompletedAtUtc | admin-output | transient-response | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | ProtocolVersion | admin-output | transient-response | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | UpdatedAtUtc | admin-output | transient-response | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent | OccurredAtUtc | integration-event | integration-message-journal | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent | Disposition | integration-event | integration-message-journal | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent | OccurredAtUtc | integration-event | integration-message-journal | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent | WorkspaceApplicationVersion | integration-event | integration-message-journal | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent | OccurredAtUtc | domain-event | transient-request | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent | Disposition | domain-event | transient-request | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent | OccurredAtUtc | domain-event | transient-request | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent | WorkspaceApplicationVersion | domain-event | transient-request | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | CycleStartedAtUtc | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | LastCompletedAtUtc | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | ProtocolVersion | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.lifecycle | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | UpdatedAtUtc | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.PrepareWorkspaceStaffIdentityAnchorSweepPageCommand | BatchSize | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance | ExpectedAfterOrdinal | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance | ExpectedCheckpointVersion | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance | NextAfterOrdinal | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepCandidate | IdentityAnchorSweepOrdinal | projection-export | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage | CheckpointVersion | projection-export | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage | ExpectedAfterOrdinal | projection-export | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage | NextAfterOrdinal | projection-export | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage | UpperOrdinal | projection-export | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | AfterOrdinal | admin-output | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | CheckpointVersion | admin-output | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | CycleUpperOrdinal | admin-output | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | LastCompletedObservedBacklogCount | admin-output | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | LastCompletedUpperOrdinal | admin-output | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.ReconcileWorkspaceStaffIdentityAnchorsPayload | BatchSize | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.ReconcileWorkspaceStaffIdentityAnchorsPayload | MaxBatches | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | AfterOrdinal | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | CycleAlreadyObservedCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | CycleConflictCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | CycleDeferredCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | CycleNoAnchorCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | CycleObservedCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | CyclePassOneCommittedCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | CycleRemovedCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | CycleResolutionRecordConfirmedCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | CycleScannedCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | CycleUpperOrdinal | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | LastCompletedAlreadyObservedCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | LastCompletedConflictCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | LastCompletedDeferredCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | LastCompletedNoAnchorCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | LastCompletedObservedCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | LastCompletedPassOneCommittedCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | LastCompletedRemovedCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | LastCompletedResolutionRecordConfirmedCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | LastCompletedScannedCount | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | LastCompletedUpperOrdinal | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepCheckpoint | Version | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | AlreadyObservedCount | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | AlreadyObservedCount | admin-output | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | BacklogCount | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | BacklogCount | admin-output | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | ConflictCount | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | ConflictCount | admin-output | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | DeferredCount | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | DeferredCount | admin-output | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | NoAnchorCount | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | NoAnchorCount | admin-output | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | ObservedCount | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | ObservedCount | admin-output | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | PassOneCommittedCount | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | PassOneCommittedCount | admin-output | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | RemovedCount | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | RemovedCount | admin-output | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | ResolutionRecordConfirmedCount | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | ResolutionRecordConfirmedCount | admin-output | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | ScannedCount | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | ScannedCount | admin-output | transient-response | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | SettledCount | application-command | transient-request | +| workspaces.identity-anchor-sweep.operational-control | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffIdentityAnchorSweepPageCounts | SettledCount | admin-output | transient-response | +| workspaces.identity-anchor-sweep.structured-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.AdvanceWorkspaceStaffIdentityAnchorSweepCommand | Advance | application-command | transient-request | +| workspaces.identity-anchor-sweep.structured-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepAdvance | Counts | application-command | transient-request | +| workspaces.identity-anchor-sweep.structured-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepCandidateResult | ResolutionRequest | projection-export | transient-response | +| workspaces.identity-anchor-sweep.structured-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepPage | Candidates | projection-export | transient-response | +| workspaces.identity-anchor-sweep.structured-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | CurrentCycle | admin-output | transient-response | +| workspaces.identity-anchor-sweep.structured-control | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Models.WorkspaceStaffIdentityAnchorSweepStatus | LastCompletedCycle | admin-output | transient-response | +| workspaces.identity-anchor-sweep.structured-control | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffIdentityAnchorSweepCheckpointDataRightsExport | Checkpoint | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor.continuation-event-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | IdentityAnchorContinuationEventId | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor.continuation-event-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | IdentityAnchorContinuationEventId | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor.expected-resolution-event-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | IdentityAnchorExpectedResolutionEventId | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor.expected-resolution-event-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | IdentityAnchorExpectedResolutionEventId | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor.resolution-application-version | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | IdentityAnchorResolutionApplicationVersion | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor.resolution-application-version | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | IdentityAnchorResolutionApplicationVersion | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor.resolution-disposition | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | IdentityAnchorResolutionDisposition | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor.resolution-disposition | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | IdentityAnchorResolutionDisposition | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor.resolution-event-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | IdentityAnchorResolutionEventId | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor.resolution-event-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | IdentityAnchorResolutionEventId | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor.resolution-intent-at-utc | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | IdentityAnchorResolutionIntentAtUtc | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor.resolution-intent-at-utc | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | IdentityAnchorResolutionIntentAtUtc | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor.resolution-observed-at-utc | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | IdentityAnchorResolutionObservedAtUtc | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor.resolution-observed-at-utc | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | IdentityAnchorResolutionObservedAtUtc | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor.resolution-staff-member-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | IdentityAnchorResolutionStaffMemberId | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor.resolution-staff-member-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | IdentityAnchorResolutionStaffMemberId | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.identity-anchor.sweep-ordinal | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | IdentityAnchorSweepOrdinal | persistence | workspace-onboarding-lifecycle | +| workspaces.identity-anchor.sweep-ordinal | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | IdentityAnchorSweepOrdinal | data-rights-export | workspaces-data-rights-export-fragment | | 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 +666,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 +710,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 | @@ -483,6 +766,8 @@ Engineering metadata is not legal or country-launch approval. | workspaces.membership-lifecycle | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationMembershipChangedIntegrationEvent | Role | integration-event | integration-message-journal | | workspaces.membership-lifecycle | Gma.Modules.Organizations.Contracts | Gma.Modules.Organizations.Contracts.OrganizationMembershipChangedIntegrationEvent | Status | integration-event | integration-message-journal | | workspaces.onboarding-application-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.RetryWorkspaceStaffOnboardingCommand | ApplicationId | application-command | transient-request | +| workspaces.onboarding-application-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Ports.WorkspaceStaffIdentityAnchorSourcePage | NextApplicationId | projection-export | transient-response | +| workspaces.onboarding-application-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Ports.WorkspaceStaffIdentityAnchorSourceRecord | ApplicationId | projection-export | transient-response | | workspaces.onboarding-application-id | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingDto | ApplicationId | api-response | transient-response | | workspaces.onboarding-application-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | Id | persistence | workspace-onboarding-lifecycle | | workspaces.onboarding-application-id | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | Id | data-rights-export | workspaces-data-rights-export-fragment | @@ -597,6 +882,7 @@ Engineering metadata is not legal or country-launch approval. | workspaces.onboarding-restriction.tenant-scope-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.DataRights.WorkspaceStaffOnboardingProcessingRestrictionProjection | ScopeId | persistence | workspace-onboarding-processing-restriction-lifecycle | | workspaces.onboarding-restriction.tenant-scope-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.DataRights.WorkspaceStaffOnboardingProcessingRestrictionReceipt | ScopeId | persistence | workspace-onboarding-processing-restriction-receipt | | workspaces.onboarding-restriction.tenant-scope-id | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.Events.WorkspaceStaffOnboardingProcessingRestrictionChangedDomainEvent | ScopeId | domain-event | transient-request | +| workspaces.onboarding-status | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Ports.WorkspaceStaffIdentityAnchorSourceRecord | Status | projection-export | transient-response | | workspaces.onboarding-status | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingDto | Status | api-response | transient-response | | workspaces.onboarding-status | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | Status | persistence | workspace-onboarding-lifecycle | | workspaces.onboarding-status | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | Status | data-rights-export | workspaces-data-rights-export-fragment | @@ -670,6 +956,7 @@ Engineering metadata is not legal or country-launch approval. | workspaces.proposed-work-phone | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingDto | WorkPhone | api-response | transient-response | | workspaces.proposed-work-phone | BunkFy.Modules.Workspaces.Domain | BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboarding | WorkPhone | persistence | workspace-onboarding-applicant-copy | | workspaces.proposed-work-phone | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | WorkPhone | data-rights-export | workspaces-data-rights-export-fragment | +| workspaces.response-items | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Ports.WorkspaceStaffIdentityAnchorSourcePage | Records | projection-export | transient-response | | workspaces.response-items | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffAccessProcessListResponse | Items | admin-output | transient-response | | workspaces.response-items | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffJoinSourceListResponse | Items | api-response | transient-response | | workspaces.response-items | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingListResponse | Items | api-response | transient-response | @@ -811,6 +1098,7 @@ Engineering metadata is not legal or country-launch approval. | workspaces.staff-lifecycle-status | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffMemberLifecycleChangedIntegrationEvent | Status | integration-event | integration-message-journal | | workspaces.staff-member-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffMemberLifecycleChangedIntegrationEvent | StaffMemberId | integration-event | integration-message-journal | | workspaces.staff-member-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ScrubWorkspaceStaffRetentionCorrelationCommand | StaffMemberId | application-command | transient-request | +| workspaces.staff-member-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Ports.WorkspaceStaffIdentityAnchorSourceRecord | StaffMemberId | projection-export | transient-response | | workspaces.staff-member-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Ports.WorkspaceStaffRetentionCorrelationScrubRequest | StaffMemberId | application-command | transient-request | | workspaces.staff-member-id | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffAccessProcessDto | StaffMemberId | admin-output | transient-response | | workspaces.staff-member-id | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingDto | StaffMemberId | api-response | transient-response | @@ -931,17 +1219,21 @@ Engineering metadata is not legal or country-launch approval. | workspaces.verified-account-email | BunkFy.Modules.Workspaces.Persistence | BunkFy.Modules.Workspaces.Persistence.Repositories.WorkspaceStaffOnboardingDataRightsExport | VerifiedAccountEmail | data-rights-export | workspaces-data-rights-export-fragment | | workspaces.workspace-scope-id | BunkFy.Modules.Staff.Contracts | BunkFy.Modules.Staff.Contracts.StaffMemberLifecycleChangedIntegrationEvent | ScopeId | integration-event | integration-message-journal | | workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Commands.ScrubWorkspaceStaffRetentionCorrelationCommand | TenantId | application-command | transient-request | +| workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Ports.IWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence | CanMutateAsync.tenantId | application-query | transient-request | | workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Application | BunkFy.Modules.Workspaces.Application.Ports.WorkspaceStaffRetentionCorrelationScrubRequest | TenantId | application-command | transient-request | | workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffAccessProcessDto | OrganizationId | admin-output | transient-response | | workspaces.workspace-scope-id | BunkFy.Modules.Workspaces.Contracts | BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingDto | OrganizationId | api-response | transient-response | | 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/AdminCli/WorkspaceIdentityAnchorAdminCliExecutionTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/AdminCli/WorkspaceIdentityAnchorAdminCliExecutionTests.cs new file mode 100644 index 00000000..8538a75c --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/AdminCli/WorkspaceIdentityAnchorAdminCliExecutionTests.cs @@ -0,0 +1,704 @@ +namespace BunkFy.Modules.Workspaces.Tests.AdminCli; + +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Text.Json; +using BunkFy.Modules.Workspaces.Admin.Contracts; +using BunkFy.Modules.Workspaces.AdminCli; +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Administration; +using Gma.Framework.Administration.Cli; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; +using Gma.Framework.Tenancy; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +[Collection(WorkspaceIdentityAnchorAdminCliProcessIsolation.Name)] +[Trait("Category", "Unit")] +public sealed class WorkspaceIdentityAnchorAdminCliExecutionTests +{ + private const string TenantId = "tenant-a"; + private const string ReviewerId = "authenticated-reviewer"; + private const string SourceSha256 = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private const string StateSha256 = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + private const string OwnerSha256 = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + private const string HistoricalEvidenceSha256 = + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + private const string ReceiptSha256 = + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + private const string CanonicalSha256 = + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + private static readonly Guid OperationId = + Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid ApplicationId = + Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly Guid EvidenceManifestId = + Guid.Parse("33333333-3333-3333-3333-333333333333"); + private static readonly DateTimeOffset ReviewedAtUtc = new( + 2026, + 8, + 11, + 18, + 0, + 0, + TimeSpan.Zero); + + [Fact] + public async Task Historical_review_requires_its_dedicated_permission_and_explicit_confirmation() + { + using AdminCliHarness harness = new(); + harness.Dispatcher.Handle< + ReviewWorkspaceStaffHistoricalNoProvisionCommand, + WorkspaceStaffHistoricalNoProvisionDispositionResult>( + (_, _) => Task.FromResult(Result.Success( + ValidHistoricalResult()))); + + AdminCliExecution denied = await harness.ExecuteAtHostBoundaryAsync( + HistoricalArgs(includeConfirmation: true)); + + Assert.Equal(AdminExitCodes.Unauthorized, denied.ExitCode); + Assert.Empty(harness.Dispatcher.Commands); + AuthorizationAttempt deniedAttempt = Assert.Single( + harness.Authorization.Attempts); + Assert.Equal( + WorkspacesAdminPermissions + .IdentityAnchorsHistoricalNoProvisionReview.Code, + deniedAttempt.Permission); + Assert.Equal(ReviewerId, deniedAttempt.ActorId); + Assert.Equal(TenantId, deniedAttempt.TenantId); + AdminAuditRecord deniedAudit = Assert.Single(harness.Audit.Records); + Assert.Equal( + WorkspacesAdminOperationNames + .IdentityAnchorsHistoricalNoProvisionReview, + deniedAudit.Operation); + Assert.Equal(AdminAuditResult.Denied, deniedAudit.Result); + + harness.Authorization.Allow( + WorkspacesAdminPermissions + .IdentityAnchorsHistoricalNoProvisionReview.Code); + + AdminCliExecution unconfirmed = + await harness.ExecuteAtHostBoundaryAsync( + HistoricalArgs(includeConfirmation: false)); + + Assert.Equal(AdminExitCodes.Failed, unconfirmed.ExitCode); + Assert.Contains( + AdminErrors.ConfirmationRequired.Message, + unconfirmed.Error, + StringComparison.Ordinal); + Assert.Empty(harness.Dispatcher.Commands); + AdminAuditRecord confirmationAudit = Assert.Single( + harness.Audit.Records, + record => + record.ErrorCode == AdminErrors.ConfirmationRequired.Code); + Assert.Equal(AdminAuditResult.Failed, confirmationAudit.Result); + } + + [Fact] + public async Task Historical_review_uses_the_scoped_actor_and_waits_for_the_transactional_dispatch_result() + { + using AdminCliHarness harness = new(); + harness.Authorization.Allow( + WorkspacesAdminPermissions + .IdentityAnchorsHistoricalNoProvisionReview.Code); + TaskCompletionSource< + Result> + completion = new( + TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource + dispatched = new(TaskCreationOptions.RunContinuationsAsynchronously); + harness.Dispatcher.Handle< + ReviewWorkspaceStaffHistoricalNoProvisionCommand, + WorkspaceStaffHistoricalNoProvisionDispositionResult>( + (command, _) => + { + dispatched.TrySetResult(command); + return completion.Task; + }); + + Task executionTask = + harness.ExecuteAtHostBoundaryAsync( + HistoricalArgs(includeConfirmation: true)); + ReviewWorkspaceStaffHistoricalNoProvisionCommand command = + await dispatched.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.False(executionTask.IsCompleted); + Assert.Equal(ReviewerId, command.ReviewerId); + Assert.Equal(OperationId, command.OperationId); + Assert.Equal(ApplicationId, command.ApplicationId); + Assert.IsType>( + command, + exactMatch: false); + + completion.SetResult(Result.Success(ValidHistoricalResult())); + AdminCliExecution execution = await executionTask; + + Assert.Equal(AdminExitCodes.Success, execution.ExitCode); + Assert.Contains(OperationId.ToString("D"), execution.Output); + AdminAuditRecord audit = Assert.Single(harness.Audit.Records); + Assert.Equal(ReviewerId, audit.ActorId); + Assert.Equal(AdminAuditResult.Succeeded, audit.Result); + + AdminCliExecution spoofAttempt = + await harness.ExecuteAtHostBoundaryAsync( + [.. HistoricalArgs(includeConfirmation: true), + "--reviewer-id", "spoofed-reviewer"]); + + Assert.NotEqual(AdminExitCodes.Success, spoofAttempt.ExitCode); + Assert.Single(harness.Dispatcher.Commands); + } + + [Fact] + public async Task Historical_review_malformed_success_and_dispatch_failure_are_stable_nonzero_results() + { + using AdminCliHarness harness = new(); + harness.Authorization.Allow( + WorkspacesAdminPermissions + .IdentityAnchorsHistoricalNoProvisionReview.Code); + Queue> + results = new( + [ + Result.Success(ValidHistoricalResult() with + { + ReceiptId = Guid.Empty + }), + Result.Failure< + WorkspaceStaffHistoricalNoProvisionDispositionResult>( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .ExternalEvidenceUnavailable) + ]); + harness.Dispatcher.Handle< + ReviewWorkspaceStaffHistoricalNoProvisionCommand, + WorkspaceStaffHistoricalNoProvisionDispositionResult>( + (_, _) => Task.FromResult(results.Dequeue())); + + AdminCliExecution malformed = + await harness.ExecuteAtHostBoundaryAsync( + HistoricalArgs(includeConfirmation: true)); + AdminCliExecution failed = + await harness.ExecuteAtHostBoundaryAsync( + HistoricalArgs(includeConfirmation: true)); + + Assert.Equal(AdminExitCodes.Failed, malformed.ExitCode); + Assert.Contains( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .Conflict.Message, + malformed.Error, + StringComparison.Ordinal); + Assert.Equal(AdminExitCodes.Failed, failed.ExitCode); + Assert.Contains( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .ExternalEvidenceUnavailable.Message, + failed.Error, + StringComparison.Ordinal); + Assert.Equal(2, harness.Dispatcher.Commands.Count); + Assert.All( + harness.Audit.Records, + record => Assert.Equal(AdminAuditResult.Failed, record.Result)); + } + + [Fact] + public async Task Reconcile_requires_permission_and_confirmation_before_dispatch() + { + using ManifestFile manifest = new(); + using AdminCliHarness harness = new(); + harness.Dispatcher.Handle< + ReconcileWorkspaceStaffIdentityAnchorsCommand, + WorkspaceStaffIdentityAnchorReconcileResult>( + (_, _) => Task.FromResult(Result.Success( + ValidReconcileResult()))); + + AdminCliExecution denied = await harness.ExecuteAtHostBoundaryAsync( + ReconcileArgs(manifest.Path, includeConfirmation: true)); + + Assert.Equal(AdminExitCodes.Unauthorized, denied.ExitCode); + Assert.Empty(harness.Dispatcher.Commands); + AuthorizationAttempt deniedAttempt = Assert.Single( + harness.Authorization.Attempts); + Assert.Equal( + WorkspacesAdminPermissions.IdentityAnchorsReconcile.Code, + deniedAttempt.Permission); + + harness.Authorization.Allow( + WorkspacesAdminPermissions.IdentityAnchorsReconcile.Code); + AdminCliExecution unconfirmed = + await harness.ExecuteAtHostBoundaryAsync( + ReconcileArgs(manifest.Path, includeConfirmation: false)); + + Assert.Equal(AdminExitCodes.Failed, unconfirmed.ExitCode); + Assert.Contains( + AdminErrors.ConfirmationRequired.Message, + unconfirmed.Error, + StringComparison.Ordinal); + Assert.Empty(harness.Dispatcher.Commands); + } + + [Fact] + public async Task Reconcile_indeterminate_malformed_and_failed_dispatch_results_are_nonzero() + { + using ManifestFile manifest = new(); + using AdminCliHarness harness = new(); + harness.Authorization.Allow( + WorkspacesAdminPermissions.IdentityAnchorsReconcile.Code); + WorkspaceStaffIdentityAnchorReconcileResult valid = + ValidReconcileResult(); + Queue> results = + new( + [ + Result.Success(valid with + { + Outcome = WorkspaceStaffIdentityAnchorReconcileOutcome + .ApplyOutcomeUnknown, + MustRerunStatus = true + }), + Result.Success(valid with + { + Outcome = WorkspaceStaffIdentityAnchorReconcileOutcome + .Unknown + }), + Result.Failure< + WorkspaceStaffIdentityAnchorReconcileResult>( + WorkspaceStaffIdentityAnchorCutoverErrors + .SourceEvidenceChanged) + ]); + harness.Dispatcher.Handle< + ReconcileWorkspaceStaffIdentityAnchorsCommand, + WorkspaceStaffIdentityAnchorReconcileResult>( + (_, _) => Task.FromResult(results.Dequeue())); + + AdminCliExecution indeterminate = + await harness.ExecuteAtHostBoundaryAsync( + ReconcileArgs(manifest.Path, includeConfirmation: true)); + AdminCliExecution malformed = + await harness.ExecuteAtHostBoundaryAsync( + ReconcileArgs(manifest.Path, includeConfirmation: true)); + AdminCliExecution failed = + await harness.ExecuteAtHostBoundaryAsync( + ReconcileArgs(manifest.Path, includeConfirmation: true)); + + Assert.Equal(AdminExitCodes.Failed, indeterminate.ExitCode); + Assert.Contains("ApplyOutcomeUnknown", indeterminate.Output); + Assert.Contains( + "do not retry this reconcile request", + indeterminate.Output, + StringComparison.Ordinal); + Assert.Equal(AdminExitCodes.Failed, malformed.ExitCode); + Assert.Contains( + WorkspaceStaffIdentityAnchorCutoverErrors + .ApplyOutcomeUnknown.Message, + malformed.Error, + StringComparison.Ordinal); + Assert.Equal(AdminExitCodes.Failed, failed.ExitCode); + Assert.Contains( + WorkspaceStaffIdentityAnchorCutoverErrors + .SourceEvidenceChanged.Message, + failed.Error, + StringComparison.Ordinal); + Assert.Equal(3, harness.Dispatcher.Commands.Count); + } + + [Fact] + public async Task Both_governed_commands_map_dispatch_cancellation_to_the_host_failure_exit_code() + { + using ManifestFile manifest = new(); + using AdminCliHarness harness = new(); + harness.Authorization.Allow( + WorkspacesAdminPermissions + .IdentityAnchorsHistoricalNoProvisionReview.Code); + harness.Authorization.Allow( + WorkspacesAdminPermissions.IdentityAnchorsReconcile.Code); + CancellationToken canceled = new(canceled: true); + harness.Dispatcher.Handle< + ReviewWorkspaceStaffHistoricalNoProvisionCommand, + WorkspaceStaffHistoricalNoProvisionDispositionResult>( + (_, _) => Task.FromCanceled>( + canceled)); + harness.Dispatcher.Handle< + ReconcileWorkspaceStaffIdentityAnchorsCommand, + WorkspaceStaffIdentityAnchorReconcileResult>( + (_, _) => Task.FromCanceled>(canceled)); + + AdminCliExecution historical = + await harness.ExecuteAtHostBoundaryAsync( + HistoricalArgs(includeConfirmation: true)); + AdminCliExecution reconcile = + await harness.ExecuteAtHostBoundaryAsync( + ReconcileArgs(manifest.Path, includeConfirmation: true)); + + Assert.Equal(AdminExitCodes.Failed, historical.ExitCode); + Assert.Equal(AdminExitCodes.Failed, reconcile.ExitCode); + Assert.Contains("Admin command was canceled.", historical.Error); + Assert.Contains("Admin command was canceled.", reconcile.Error); + Assert.Equal(2, harness.Audit.Records.Count); + Assert.All( + harness.Audit.Records, + record => + { + Assert.Equal(AdminAuditResult.Canceled, record.Result); + Assert.Equal( + AdminErrors.OperationCanceled.Code, + record.ErrorCode); + }); + } + + private static string[] HistoricalArgs(bool includeConfirmation) + { + List args = + [ + "workspaces", + "identity-anchors", + "review-historical-no-provision", + "--actor", + ReviewerId, + "--tenant", + TenantId, + "--operation-id", + OperationId.ToString("D"), + "--application-id", + ApplicationId.ToString("D"), + "--application-version", + "3", + "--application-status", + WorkspaceStaffOnboardingState.Submitted.ToString(), + "--organizations-revision", + "7", + "--organizations-source-version", + "11", + "--organizations-source-status", + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationRevoked.ToString(), + "--evidence-manifest-id", + EvidenceManifestId.ToString("D"), + "--evidence-sha256", + HistoricalEvidenceSha256, + "--output", + "json" + ]; + if (includeConfirmation) + { + args.Add("--yes"); + } + + return [.. args]; + } + + private static string[] ReconcileArgs( + string ownerManifestPath, + bool includeConfirmation) + { + List args = + [ + "workspaces", + "identity-anchors", + "reconcile", + "--actor", + ReviewerId, + "--tenant", + TenantId, + "--expected-source-evidence-sha256", + SourceSha256, + "--expected-anchor-state-sha256", + StateSha256, + "--owner-map", + ownerManifestPath, + "--expected-owner-manifest-sha256", + OwnerSha256, + "--batch-size", + "100", + "--output", + "json" + ]; + if (includeConfirmation) + { + args.Add("--yes"); + } + + return [.. args]; + } + + private static WorkspaceStaffHistoricalNoProvisionDispositionResult + ValidHistoricalResult() => new( + Guid.Parse("44444444-4444-4444-4444-444444444444"), + OperationId, + ApplicationId, + ResultApplicationVersion: 4, + WorkspaceStaffOnboardingState.Superseded, + ReceiptSha256, + CanonicalSha256, + ReviewedAtUtc, + AlreadyReviewed: false); + + private static WorkspaceStaffIdentityAnchorReconcileResult + ValidReconcileResult() => new( + AppliedCount: 1, + Status: new WorkspaceStaffIdentityAnchorCutoverStatus( + WorkspaceSourceCount: 1, + OwnerBindingCount: 0, + AlreadyAnchoredCount: 1, + SeedableWorkspaceCount: 0, + SeedableOwnerCount: 0, + AmbiguousCount: 0, + ConflictCount: 0, + SourceSha256, + StateSha256, + OwnerSha256, + OwnerManifestProvided: true, + CanReconcile: true, + IsReady: true, + AuthoritativeOwnerCount: 0, + OrganizationsScopeRevision: 7, + HistoricalBindingCount: 0, + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + .ReviewedNoHistoricalOwnerSources, + HistoricalEvidenceSha256, + TotalIssueCount: 0, + HasMoreIssues: false, + Issues: []), + WorkspaceStaffIdentityAnchorReconcileOutcome.AppliedAndVerified, + MustRerunStatus: false, + SourceSha256, + StateSha256, + OwnerSha256); + + private sealed class ManifestFile : IDisposable + { + public ManifestFile() + { + this.Path = System.IO.Path.GetTempFileName(); + WorkspaceStaffIdentityAnchorOwnerManifest manifest = new( + ContractVersion: 1, + TenantId, + ReviewedAtUtc, + new WorkspaceStaffIdentityAnchorHistoricalEvidence( + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + .ReviewedNoHistoricalOwnerSources, + HistoricalEvidenceSha256), + Bindings: []); + File.WriteAllText( + this.Path, + JsonSerializer.Serialize(manifest)); + } + + public string Path { get; } + + public void Dispose() => File.Delete(this.Path); + } + + private sealed class AdminCliHarness : IDisposable + { + private readonly ServiceProvider services; + private readonly RootCommand root; + + public AdminCliHarness() + { + this.Authorization = new RecordingAuthorizationService(); + this.Audit = new RecordingAuditSink(); + this.Dispatcher = new RecordingRequestDispatcher(); + + ServiceCollection registrations = new(); + registrations.AddLogging(); + registrations.AddScoped(); + registrations.AddSingleton(); + registrations.AddSingleton(); + registrations.AddGmaAdministrationCli(); + registrations.AddScoped( + _ => this.Authorization); + registrations.AddScoped(_ => this.Audit); + registrations.AddSingleton(this.Dispatcher); + this.services = registrations.BuildServiceProvider( + new ServiceProviderOptions + { + ValidateScopes = true, + ValidateOnBuild = true + }); + + AdminCliGlobalOptions options = this.services + .GetRequiredService(); + this.root = new RootCommand("Workspaces admin CLI") + { + options.ActorOption, + options.TenantOption, + options.OutputOption + }; + new WorkspacesAdminCliModule().MapCommands( + new AdminCliCommandRegistry(this.root, this.services)); + } + + public RecordingAuthorizationService Authorization { get; } + + public RecordingAuditSink Audit { get; } + + public RecordingRequestDispatcher Dispatcher { get; } + + public async Task ExecuteAtHostBoundaryAsync( + string[] args) + { + using StringWriter output = new(); + using StringWriter error = new(); + TextWriter originalOutput = Console.Out; + TextWriter originalError = Console.Error; + Console.SetOut(output); + Console.SetError(error); + + try + { + int exitCode; + try + { + ParseResult parseResult = this.root.Parse(args); + exitCode = await parseResult.InvokeAsync( + new InvocationConfiguration + { + EnableDefaultExceptionHandler = false + }, + CancellationToken.None); + } + catch (OperationCanceledException) + { + AdminCliOutput.WriteError("Admin command was canceled."); + exitCode = AdminExitCodes.Failed; + } + + return new AdminCliExecution( + exitCode, + output.ToString(), + error.ToString()); + } + finally + { + Console.SetOut(originalOutput); + Console.SetError(originalError); + } + } + + public void Dispose() => this.services.Dispose(); + } + + private sealed class RecordingAuthorizationService : + IAdminAuthorizationService + { + private readonly HashSet allowed = new(StringComparer.Ordinal); + + public List Attempts { get; } = []; + + public void Allow(string permission) => this.allowed.Add(permission); + + public Task AuthorizeAsync( + AdminActor actor, + AdminPermission permission, + string? tenantId, + CancellationToken cancellationToken) + { + this.Attempts.Add(new( + actor.Id, + permission.Code, + tenantId)); + return Task.FromResult(this.allowed.Contains(permission.Code) + ? AdminAuthorizationResult.Allowed() + : AdminAuthorizationResult.Denied("not granted")); + } + } + + private sealed class RecordingAuditSink : IAdminAuditSink + { + public List Records { get; } = []; + + public Task RecordAsync( + AdminAuditRecord record, + CancellationToken cancellationToken) + { + this.Records.Add(record); + return Task.CompletedTask; + } + } + + private sealed class RecordingRequestDispatcher : IRequestDispatcher + { + private readonly Dictionary>> handlers = []; + + public List Commands { get; } = []; + + public void Handle( + Func>> handler) + where TCommand : ICommand + { + this.handlers[typeof(TCommand)] = async (command, token) => + await handler((TCommand)command, token); + } + + public async Task> SendAsync( + ICommand command, + CancellationToken cancellationToken = default) + { + this.Commands.Add(command); + if (!this.handlers.TryGetValue( + command.GetType(), + out Func>? + handler)) + { + throw new InvalidOperationException( + $"No dispatcher result is registered for {command.GetType().Name}."); + } + + object result = await handler(command, cancellationToken); + return (Result)result; + } + + public Task> QueryAsync( + IQuery query, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException( + $"Unexpected query {query.GetType().Name}."); + } + + private sealed class EnabledTenantContext : ITenantContextAccessor + { + public bool IsEnabled => true; + + public string? TenantId { get; private set; } + + public void SetTenant(string tenantId) => this.TenantId = tenantId; + + public void ClearTenant() => this.TenantId = null; + } + + private sealed class FixedClock : ISystemClock + { + public DateTimeOffset UtcNow => ReviewedAtUtc; + } + + private sealed class RandomIdGenerator : IIdGenerator + { + public Guid NewId() => Guid.NewGuid(); + } + + private sealed record AdminCliExecution( + int ExitCode, + string Output, + string Error); + + private sealed record AuthorizationAttempt( + string ActorId, + string Permission, + string? TenantId); +} + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class WorkspaceIdentityAnchorAdminCliProcessIsolation +{ + public const string Name = + "Workspace identity-anchor AdminCli process isolation"; +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/AdminCli/WorkspaceIdentityAnchorAdminCliResultTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/AdminCli/WorkspaceIdentityAnchorAdminCliResultTests.cs new file mode 100644 index 00000000..3b56f95f --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/AdminCli/WorkspaceIdentityAnchorAdminCliResultTests.cs @@ -0,0 +1,193 @@ +namespace BunkFy.Modules.Workspaces.Tests.AdminCli; + +using BunkFy.Modules.Workspaces.AdminCli; +using BunkFy.Modules.Workspaces.Admin.Contracts; +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Administration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class WorkspaceIdentityAnchorAdminCliResultTests +{ + private static readonly string SourceSha256 = new('a', 64); + private static readonly string StateSha256 = new('b', 64); + private static readonly string OwnerSha256 = new('c', 64); + private static readonly DateTimeOffset ReviewedAtUtc = new( + 2026, + 8, + 11, + 16, + 0, + 0, + TimeSpan.Zero); + + [Fact] + public void Verified_result_requires_a_complete_consistent_truth_shape() + { + WorkspaceStaffIdentityAnchorReconcileResult result = ValidResult(); + + Assert.True( + WorkspacesAdminCliModule + .IsVerifiedIdentityAnchorReconcileResult(result)); + } + + [Fact] + public void Unknown_or_inconsistent_success_shapes_are_not_cli_success() + { + WorkspaceStaffIdentityAnchorReconcileResult valid = ValidResult(); + WorkspaceStaffIdentityAnchorReconcileResult[] invalid = + [ + valid with + { + Outcome = WorkspaceStaffIdentityAnchorReconcileOutcome.Unknown + }, + valid with + { + Outcome = WorkspaceStaffIdentityAnchorReconcileOutcome + .ApplyOutcomeUnknown, + MustRerunStatus = true + }, + valid with { AppliedCount = null }, + valid with { AppliedCount = -1 }, + valid with { Status = null }, + valid with { MustRerunStatus = true }, + valid with { AcceptedAnchorStateSha256 = "not-a-digest" }, + valid with + { + AcceptedSourceEvidenceSha256 = new string('d', 64) + }, + valid with + { + AcceptedOwnerManifestSha256 = new string('e', 64) + } + ]; + + Assert.All(invalid, result => Assert.False( + WorkspacesAdminCliModule + .IsVerifiedIdentityAnchorReconcileResult(result))); + } + + [Fact] + public void Historical_review_result_requires_complete_terminal_truth() + { + WorkspaceStaffHistoricalNoProvisionDispositionResult result = + ValidHistoricalResult(); + + Assert.True( + WorkspacesAdminCliModule + .IsWellFormedHistoricalNoProvisionResult( + result, + expectedApplicationVersion: 3, + WorkspaceStaffOnboardingState.Submitted)); + } + + [Fact] + public void Historical_review_unknown_success_shapes_are_not_cli_success() + { + WorkspaceStaffHistoricalNoProvisionDispositionResult valid = + ValidHistoricalResult(); + WorkspaceStaffHistoricalNoProvisionDispositionResult[] invalid = + [ + valid with { ReceiptId = Guid.Empty }, + valid with { OperationId = Guid.Empty }, + valid with { ApplicationId = Guid.Empty }, + valid with { ResultApplicationVersion = 0 }, + valid with { ResultApplicationVersion = 5 }, + valid with + { + ResultApplicationStatus = WorkspaceStaffOnboardingState.Unknown + }, + valid with + { + ResultApplicationStatus = + WorkspaceStaffOnboardingState.Submitted + }, + valid with { StaffEvidenceSha256 = "not-a-digest" }, + valid with { CanonicalSha256 = "not-a-digest" }, + valid with { ReviewedAtUtc = default } + ]; + + Assert.All(invalid, result => Assert.False( + WorkspacesAdminCliModule + .IsWellFormedHistoricalNoProvisionResult( + result, + expectedApplicationVersion: 3, + WorkspaceStaffOnboardingState.Submitted))); + } + + [Fact] + public void Historical_review_has_a_stable_dedicated_operation_permission() + { + const string expected = + "workspaces.identity-anchors.historical-no-provision.review"; + + Assert.Equal( + expected, + WorkspacesAdminOperationNames + .IdentityAnchorsHistoricalNoProvisionReview); + Assert.Equal( + expected, + WorkspacesAdminPermissions + .IdentityAnchorsHistoricalNoProvisionReview.Code); + } + + [Fact] + public void Historical_review_receipt_uses_the_authenticated_admin_actor() + { + ScopedAdminActorContext actorContext = new(); + actorContext.SetActor(AdminActor.System("actual-reviewer")); + ServiceProvider services = new ServiceCollection() + .AddSingleton(actorContext) + .BuildServiceProvider(); + + Assert.Equal( + "actual-reviewer", + WorkspacesAdminCliModule.ResolveHistoricalReviewActor(services)); + } + + private static WorkspaceStaffIdentityAnchorReconcileResult ValidResult() => + new( + AppliedCount: 1, + Status: new WorkspaceStaffIdentityAnchorCutoverStatus( + WorkspaceSourceCount: 1, + OwnerBindingCount: 0, + AlreadyAnchoredCount: 1, + SeedableWorkspaceCount: 0, + SeedableOwnerCount: 0, + AmbiguousCount: 0, + ConflictCount: 0, + SourceSha256, + StateSha256, + OwnerSha256, + OwnerManifestProvided: true, + CanReconcile: true, + IsReady: true, + AuthoritativeOwnerCount: 0, + OrganizationsScopeRevision: 1, + HistoricalBindingCount: 0, + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + .ReviewedNoHistoricalOwnerSources, + HistoricalEvidenceSha256: new string('f', 64), + TotalIssueCount: 0, + HasMoreIssues: false, + Issues: []), + WorkspaceStaffIdentityAnchorReconcileOutcome.AppliedAndVerified, + MustRerunStatus: false, + SourceSha256, + StateSha256, + OwnerSha256); + + private static WorkspaceStaffHistoricalNoProvisionDispositionResult + ValidHistoricalResult() => new( + Guid.Parse("11111111-2222-3333-4444-555555555555"), + Guid.Parse("22222222-2222-3333-4444-555555555555"), + Guid.Parse("33333333-2222-3333-4444-555555555555"), + ResultApplicationVersion: 4, + WorkspaceStaffOnboardingState.Superseded, + new string('d', 64), + new string('e', 64), + ReviewedAtUtc, + AlreadyReviewed: false); +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Api/WorkspaceStaffOnboardingEndpointOutcomeTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Api/WorkspaceStaffOnboardingEndpointOutcomeTests.cs new file mode 100644 index 00000000..b0c1d9eb --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Api/WorkspaceStaffOnboardingEndpointOutcomeTests.cs @@ -0,0 +1,53 @@ +namespace BunkFy.Modules.Workspaces.Tests.Api; + +using BunkFy.Modules.Workspaces.Api; +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Contracts; +using Gma.Framework.Results; +using Microsoft.AspNetCore.Http; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class WorkspaceStaffOnboardingEndpointOutcomeTests +{ + [Fact] + public void Submission_authority_moved_success_maps_to_existing_conflict() + { + Result mapped = + WorkspaceStaffOnboardingEndpoints.MapSubmissionOutcome( + Result.Success( + WorkspaceStaffOnboardingSubmissionOutcome + .AuthorityMovedToStaff())); + + Assert.True(mapped.IsFailure); + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors + .ProfileMutationAuthorityUnavailable, + mapped.Error); + Assert.Equal( + StatusCodes.Status409Conflict, + WorkspacesApiEndpointSupport.ErrorStatusCodes.GetStatusCode( + mapped.Error)); + } + + [Fact] + public void Correction_authority_moved_success_maps_to_existing_conflict() + { + Result mapped = + WorkspaceStaffOnboardingDataRightsEndpoints.MapCorrectionOutcome( + Result.Success( + WorkspaceStaffOnboardingDataRightsCorrectionOutcome + .AuthorityMovedToStaff())); + + Assert.True(mapped.IsFailure); + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors + .CorrectionTargetUnavailable, + mapped.Error); + Assert.Equal( + StatusCodes.Status409Conflict, + WorkspacesApiEndpointSupport.ErrorStatusCodes.GetStatusCode( + mapped.Error)); + } +} 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 1375c889..5c2a03ac 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 @@ -9,6 +9,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.Framework.Security; @@ -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/ApplyWorkspaceStaffCorrelationAnonymisationCommandHandlerTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/ApplyWorkspaceStaffCorrelationAnonymisationCommandHandlerTests.cs index cbd60da2..485b1ee1 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/ApplyWorkspaceStaffCorrelationAnonymisationCommandHandlerTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/ApplyWorkspaceStaffCorrelationAnonymisationCommandHandlerTests.cs @@ -57,6 +57,9 @@ await repository.ReadAsync( ApplyWorkspaceStaffCorrelationAnonymisationCommandHandler handler = new( repository, + new + RecordingWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence( + calls: calls), crossGraphLock, WorkspaceStaffAccessMutationTestSupport.Create( calls: calls), @@ -100,7 +103,12 @@ await repository.ReadAsync( Assert.Equal(1, crossGraphLock.AcquireCount); Assert.Equal(1, operationLock.AcquireCount); Assert.Equal( - ["tenant-exclusive", "staff-coordinate", "correlation-row"], + [ + "tenant-exclusive", + "staff-coordinate", + "correlation-row", + "identity-anchor-fence" + ], calls); Assert.All( context.StaffAccessProcesses, @@ -111,6 +119,69 @@ await repository.ReadAsync( }); } + [Fact] + public async Task Unresolved_identity_anchor_blocks_before_scrub() + { + await using WorkspacesDbContext context = CreateContext(); + WorkspaceStaffAccessProcess anchor = SeedEligibleState(context); + await context.SaveChangesAsync(); + WorkspaceStaffCorrelationAnonymisationRepository repository = + new(context); + WorkspaceStaffCorrelationAnonymisationSnapshot snapshot = + await repository.ReadAsync( + TenantId, + anchor.Id, + anchor.Version, + CancellationToken.None); + DataRightsApprovalEvidence evidence = + CreateApprovalEvidence(snapshot); + List calls = []; + RecordingWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence + fence = new(allowed: false, calls: calls); + ApplyWorkspaceStaffCorrelationAnonymisationCommandHandler handler = + new( + repository, + fence, + new RecordingWorkspaceCrossGraphMutationLock(calls), + WorkspaceStaffAccessMutationTestSupport.Create(calls: calls), + new RecordingOperationLock(calls), + new RecordingApprovalGate(evidence), + new TestScopeContext(), + new TestClock(), + new FixedIdGenerator()); + + Result result = + await handler.HandleAsync( + new( + Guid.NewGuid(), + Guid.NewGuid(), + ApprovalRevision: 4, + OperationRevision: 5, + anchor.Id, + anchor.Version, + evidence, + "user:privacy-executor"), + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffCorrelationAnonymisationApplicationErrors + .IdentityAnchorUnavailable, + result.Error); + Assert.Equal(1, fence.CallCount); + Assert.Equal(TenantId, fence.TenantId); + Assert.Equal(SubjectId, fence.SubjectId); + Assert.Equal( + [ + "tenant-exclusive", + "staff-coordinate", + "correlation-row", + "identity-anchor-fence" + ], + calls); + Assert.Equal(SubjectId, anchor.SubjectId); + Assert.Empty(context.StaffCorrelationAnonymisationReceipts); + } + [Fact] public async Task Changed_workspace_binding_is_blocked_before_scrub() { @@ -147,6 +218,8 @@ .. CreateApprovalEvidence(snapshot) ApplyWorkspaceStaffCorrelationAnonymisationCommandHandler handler = new( repository, + new + RecordingWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence(), new RecordingWorkspaceCrossGraphMutationLock(), WorkspaceStaffAccessMutationTestSupport.Create(), new RecordingOperationLock(), @@ -211,6 +284,8 @@ .. valid.StateBindings! handler = new( new WorkspaceStaffCorrelationAnonymisationRepository( context), + new + RecordingWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence(), crossGraphLock, WorkspaceStaffAccessMutationTestSupport.Create(), operationLock, @@ -265,6 +340,8 @@ private static WorkspaceStaffAccessProcess SeedEligibleState( Now.AddDays(-30).AddMinutes(1)).IsSuccess); Assert.True(onboarding.MarkStaffReady( StaffMemberId, + Guid.NewGuid(), + Guid.NewGuid(), Now.AddDays(-30).AddMinutes(2)).IsSuccess); Assert.True(onboarding.Complete( Now.AddDays(-30).AddMinutes(3)).IsSuccess); 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..3a6b1bdd 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 @@ -1,6 +1,7 @@ namespace BunkFy.Modules.Workspaces.Tests; using BunkFy.Modules.DataRights.Contracts; +using BunkFy.Modules.Staff.Contracts; using BunkFy.Modules.Workspaces.Application; using BunkFy.Modules.Workspaces.Application.Authorization; using BunkFy.Modules.Workspaces.Application.Commands; @@ -14,6 +15,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 +147,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() { @@ -268,13 +511,65 @@ await handler.HandleAsync( Assert.Equal(0, gate.EvaluationCount); } + [Fact] + public async Task Staff_anchor_found_after_lock_returns_commit_shaped_moved_outcome() + { + WorkspaceStaffOnboarding application = CreateApplication(); + Guid staffMemberId = Guid.NewGuid(); + Guid resolutionEventId = Guid.NewGuid(); + InMemoryReceiptRepository receipts = new(); + ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler handler = + CreateHandler( + application, + receipts, + new RecordingCorrectionLock(), + new RecordingExecutionGate(), + anchorOutcomes: + new StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader( + request => new( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Unresolved, + staffMemberId, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Exact, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + resolutionEventId))); + + Result result = + await handler.HandleWithAuthorityOutcomeAsync( + Command(application) with + { + DisplayName = "Must not be written" + }, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal( + WorkspaceStaffOnboardingDataRightsCorrectionOutcomeKind + .AuthorityMovedToStaff, + result.Value.Kind); + Assert.Null(result.Value.Receipt); + Assert.Null(receipts.Receipt); + Assert.Equal(WorkspaceStaffOnboardingState.StaffReady, application.Status); + Assert.Equal(staffMemberId, application.StaffMemberId); + Assert.Null(application.DisplayName); + Assert.Null(application.VerifiedAccountEmail); + } + private static ApplyWorkspaceStaffOnboardingDataRightsCorrectionCommandHandler CreateHandler( WorkspaceStaffOnboarding application, InMemoryReceiptRepository receipts, RecordingCorrectionLock correctionLock, - RecordingExecutionGate gate) + RecordingExecutionGate gate, + FakeOrganizationEnrollmentClaimInspector? claims = null, + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader? + anchorOutcomes = null) { InMemoryApplicationRepository applications = new(application); return new( @@ -282,9 +577,12 @@ private static WorkspaceStaffOnboardingMutationTestSupport.Create( applications, correctionLock), + WorkspaceStaffOnboardingMutationTestSupport + .CreateIdentityAnchorConvergence(anchorOutcomes), new WorkspaceStaffOnboardingDataRightsCorrectionAuthorizer( gate, new TestScopeContext()), + claims ?? new FakeOrganizationEnrollmentClaimInspector(), new TestScopeContext(), new TestClock(), new SequenceIdGenerator()); @@ -307,11 +605,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 +624,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/GetOwnWorkspaceStaffOnboardingProfileAuthorityTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/GetOwnWorkspaceStaffOnboardingProfileAuthorityTests.cs new file mode 100644 index 00000000..11bdd6e1 --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/GetOwnWorkspaceStaffOnboardingProfileAuthorityTests.cs @@ -0,0 +1,335 @@ +namespace BunkFy.Modules.Workspaces.Tests; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Application.Handlers; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Application.Queries; +using BunkFy.Modules.Workspaces.Contracts; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Pagination; +using Gma.Framework.Results; +using Gma.Framework.Scoping; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class GetOwnWorkspaceStaffOnboardingProfileAuthorityTests +{ + private const string TenantId = + "71000000-0000-0000-0000-000000000001"; + private const string SubjectId = "subject:profile-reader"; + private static readonly Guid SourceId = + Guid.Parse("71000000-0000-0000-0000-000000000002"); + private static readonly DateTimeOffset Now = + new(2026, 8, 11, 14, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task Exact_absent_application_is_disclosed_after_all_locks() + { + WorkspaceStaffOnboarding application = CreateApplication(); + List calls = []; + GetOwnWorkspaceStaffOnboardingQueryHandler handler = CreateHandler( + application, + new RecordingOperationLock(calls), + new MutableOutcomeReader(calls), + new InlineReadBoundary(calls)); + + Result result = await handler.HandleAsync( + Query(), + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal("Applicant Profile", result.Value.DisplayName); + Assert.Equal( + ["tenant", "source", "applicant", "application", "staff"], + calls); + } + + [Fact] + public async Task Staff_commit_while_waiting_for_application_lock_cannot_leak_staging() + { + WorkspaceStaffOnboarding application = CreateApplication(); + List calls = []; + CoordinatedOperationLock operationLock = new(calls); + MutableOutcomeReader outcomes = new(calls); + GetOwnWorkspaceStaffOnboardingQueryHandler handler = CreateHandler( + application, + operationLock, + outcomes, + new InlineReadBoundary(calls)); + + Task> read = handler.HandleAsync( + Query(), + CancellationToken.None); + await operationLock.ApplicationLockRequested.Task.WaitAsync( + TimeSpan.FromSeconds(5)); + Assert.Equal(0, outcomes.ReadCount); + + outcomes.Outcome = new StaffWorkspaceOnboardingIdentityAnchorOutcome( + application.Id, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved, + Guid.Parse("71000000-0000-0000-0000-000000000003"), + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + Guid.Parse("71000000-0000-0000-0000-000000000004")); + operationLock.ReleaseApplicationLock.TrySetResult(); + + Result result = await read.WaitAsync( + TimeSpan.FromSeconds(5)); + + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors + .ProfileMutationAuthorityUnavailable, + result.Error); + Assert.Equal(1, outcomes.ReadCount); + Assert.Equal("Applicant Profile", application.DisplayName); + } + + [Fact] + public async Task Read_boundary_failure_does_not_touch_or_disclose_application() + { + WorkspaceStaffOnboarding application = CreateApplication(); + MutableOutcomeReader outcomes = new([]); + GetOwnWorkspaceStaffOnboardingQueryHandler handler = CreateHandler( + application, + new RecordingOperationLock([]), + outcomes, + new FailingReadBoundary()); + + Result result = await handler.HandleAsync( + Query(), + CancellationToken.None); + + Assert.Equal( + WorkspaceOperationalAdmissionErrors.AdmissionUnavailable, + result.Error); + Assert.Equal(0, outcomes.ReadCount); + Assert.Equal("Applicant Profile", application.DisplayName); + } + + private static GetOwnWorkspaceStaffOnboardingQueryHandler CreateHandler( + WorkspaceStaffOnboarding application, + IWorkspaceStaffOnboardingOperationLock operationLock, + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader outcomes, + IWorkspaceStaffOnboardingSerializedReadBoundary boundary) + { + ApplicationRepository applications = new(application); + return new( + WorkspaceStaffOnboardingMutationTestSupport.Create( + applications, + operationLock), + boundary, + outcomes, + WorkspaceOperationalAdmissionTestSupport.Allowed(TenantId), + new TestScopeContext()); + } + + private static GetOwnWorkspaceStaffOnboardingQuery Query() => + new( + WorkspaceStaffOnboardingSourceKind.EnrollmentLink, + SourceId, + SubjectId); + + private static WorkspaceStaffOnboarding CreateApplication() => + WorkspaceStaffOnboarding.Create( + Guid.Parse("71000000-0000-0000-0000-000000000005"), + TenantId, + WorkspaceStaffOnboardingSource.EnrollmentLink, + SourceId, + SubjectId, + "verified@example.test", + "Applicant Profile", + "Applicant Legal", + "work@example.test", + "+1 555 0100", + "EMP-710", + "Manager", + "Operations", + Now).Value; + + private sealed class InlineReadBoundary(List calls) + : IWorkspaceStaffOnboardingSerializedReadBoundary + { + public Task> RunAsync( + Func>> read, + CancellationToken cancellationToken) + { + calls.Add("tenant"); + return read(cancellationToken); + } + } + + private sealed class FailingReadBoundary + : IWorkspaceStaffOnboardingSerializedReadBoundary + { + public Task> RunAsync( + Func>> read, + CancellationToken cancellationToken) => + Task.FromResult(Result.Failure( + WorkspaceOperationalAdmissionErrors.AdmissionUnavailable)); + } + + private class RecordingOperationLock(List calls) + : IWorkspaceStaffOnboardingOperationLock + { + protected List Calls { get; } = calls; + + public virtual Task AcquireSourceReadAsync( + Guid sourceId, + CancellationToken cancellationToken) + { + this.Calls.Add("source"); + return Task.CompletedTask; + } + + public Task AcquireSourceWriteAsync( + Guid sourceId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public virtual Task AcquireApplicantAsync( + Guid sourceId, + string subjectId, + CancellationToken cancellationToken) + { + this.Calls.Add("applicant"); + return Task.CompletedTask; + } + + public virtual Task TryAcquireAsync( + Guid applicationId, + CancellationToken cancellationToken) + { + this.Calls.Add("application"); + return Task.FromResult(true); + } + } + + private sealed class CoordinatedOperationLock(List calls) + : RecordingOperationLock(calls) + { + public TaskCompletionSource ApplicationLockRequested { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReleaseApplicationLock { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override async Task TryAcquireAsync( + Guid applicationId, + CancellationToken cancellationToken) + { + this.Calls.Add("application-wait"); + this.ApplicationLockRequested.TrySetResult(); + await this.ReleaseApplicationLock.Task.WaitAsync(cancellationToken); + this.Calls.Add("application"); + return true; + } + } + + private sealed class MutableOutcomeReader(List calls) + : IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + { + public StaffWorkspaceOnboardingIdentityAnchorOutcome? Outcome + { + get; + set; + } + + public int ReadCount { get; private set; } + + public Task> ReadAsync( + IReadOnlyList + requests, + CancellationToken cancellationToken = default) + { + this.ReadCount++; + calls.Add("staff"); + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest request = + Assert.Single(requests); + return Task.FromResult>([ + this.Outcome ?? + StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + .Absent(request) + ]); + } + } + + private sealed class ApplicationRepository( + WorkspaceStaffOnboarding application) + : IWorkspaceStaffOnboardingRepository + { + public Task FindIdBySourceAndSubjectAsync( + WorkspaceStaffOnboardingSource sourceKind, + Guid sourceId, + string subjectId, + CancellationToken cancellationToken) => + Task.FromResult( + application.SourceKind == sourceKind && + application.SourceId == sourceId && + string.Equals( + application.SubjectId, + subjectId, + StringComparison.Ordinal) + ? application.Id + : null); + + public Task GetOperationalAsync( + Guid applicationId, + CancellationToken cancellationToken) => + Task.FromResult( + application.Id == applicationId ? application : null); + + public Task FindCoordinateAsync( + Guid applicationId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetAsync( + Guid applicationId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task + GetOperationalBySourceAndSubjectAsync( + WorkspaceStaffOnboardingSource sourceKind, + Guid sourceId, + string subjectId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetByClaimAsync( + Guid claimId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task> + ListActiveBySourceAsync( + WorkspaceStaffOnboardingSource sourceKind, + Guid sourceId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task ListActionableAsync( + PageRequest page, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task ReloadAsync( + WorkspaceStaffOnboarding candidate, + CancellationToken cancellationToken) => Task.CompletedTask; + + public Task AddAsync( + WorkspaceStaffOnboarding candidate, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + + private sealed class TestScopeContext : IScopeContext + { + public bool IsEnabled => true; + public string? ScopeId => TenantId; + } +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQueryHandlerTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQueryHandlerTests.cs index 2bd930b5..e069ed1b 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQueryHandlerTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQueryHandlerTests.cs @@ -1,14 +1,15 @@ namespace BunkFy.Modules.Workspaces.Tests; using BunkFy.Modules.DataRights.Contracts; +using BunkFy.Modules.Staff.Contracts; using BunkFy.Modules.Workspaces.Application; using BunkFy.Modules.Workspaces.Application.Authorization; using BunkFy.Modules.Workspaces.Application.Handlers; -using BunkFy.Modules.Workspaces.Application.Models; using BunkFy.Modules.Workspaces.Application.Ports; using BunkFy.Modules.Workspaces.Application.Queries; using BunkFy.Modules.Workspaces.Contracts; using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Pagination; using Gma.Framework.Results; using Gma.Framework.Scoping; using Xunit; @@ -25,75 +26,115 @@ public sealed class new(2026, 7, 30, 9, 0, 0, TimeSpan.Zero); [Fact] - public async Task Approved_exact_target_is_returned_without_identity_fields() + public async Task Approved_exact_absent_target_is_returned_under_lock() { + WorkspaceStaffOnboarding application = CreateApplication(); RecordingExecutionGate gate = new(); + List calls = []; GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQueryHandler - handler = CreateHandler( - new WorkspaceStaffOnboardingDataRightsCorrectionTarget( - ApplicationId, - Version: 3, - WorkspaceStaffOnboardingState.Submitted, - "Ada Operator", - "Ada Lovelace", - "ada@example.test", - "+1 555 0100", - "EMP-100", - "Manager", - "Operations"), - gate); - GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQuery query = - Query(); + handler = CreateHandler(application, gate, calls: calls); Result result = - await handler.HandleAsync(query, CancellationToken.None); + await handler.HandleAsync( + Query(application.Version), + CancellationToken.None); Assert.True(result.IsSuccess, result.Error.Code); Assert.Equal(ApplicationId, result.Value.ApplicationId); - Assert.Equal(3, result.Value.Version); + Assert.Equal(application.Version, result.Value.Version); Assert.Equal("Ada Operator", result.Value.DisplayName); Assert.Equal("ada@example.test", result.Value.WorkEmail); + Assert.Equal(["tenant", "source", "application", "staff"], calls); Assert.DoesNotContain( result.Value.GetType().GetProperties(), - property => string.Equals( - property.Name, - "SubjectId", - StringComparison.Ordinal) || - string.Equals( - property.Name, - "VerifiedAccountEmail", - StringComparison.Ordinal)); + property => property.Name is "SubjectId" or + "VerifiedAccountEmail"); Assert.Equal( WorkspacesDataRightsCoordinates .StaffOnboardingCorrectionFieldPolicyKey, gate.Request!.FieldPolicyKey); - Assert.Equal(query.ActorId, gate.Request.ExecutingActorId); + } + + [Fact] + public async Task Reviewed_target_is_not_disclosed() + { + WorkspaceStaffOnboarding application = CreateApplication(); + Assert.True(application.ObserveClaimRequested( + Guid.NewGuid(), + claimVersion: 1, + Now.AddMinutes(1)).IsSuccess); + GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQueryHandler + handler = CreateHandler(application, new RecordingExecutionGate()); + + Result result = + await handler.HandleAsync( + Query(application.Version), + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors + .CorrectionTargetUnavailable, + result.Error); + } + + [Fact] + public async Task Stale_target_is_not_disclosed() + { + WorkspaceStaffOnboarding application = CreateApplication(); + GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQueryHandler + handler = CreateHandler(application, new RecordingExecutionGate()); + + Result result = + await handler.HandleAsync( + Query(application.Version + 1), + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors + .CorrectionTargetUnavailable, + result.Error); } [Theory] - [InlineData(WorkspaceStaffOnboardingState.PendingApproval, 3)] - [InlineData(WorkspaceStaffOnboardingState.Submitted, 4)] - public async Task Reviewed_or_stale_target_is_not_disclosed( - WorkspaceStaffOnboardingState status, - long version) + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact)] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Absent, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Unknown)] + public async Task Non_absent_or_malformed_absent_target_is_not_disclosed( + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus status, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle lifecycle, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch subjectMatch) { + WorkspaceStaffOnboarding application = CreateApplication(); + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome = new( + application.Id, + status, + status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved + ? Guid.NewGuid() + : null, + lifecycle, + subjectMatch, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + ResolutionEventId: status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved + ? Guid.NewGuid() + : null); GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQueryHandler handler = CreateHandler( - new WorkspaceStaffOnboardingDataRightsCorrectionTarget( - ApplicationId, - version, - status, - "Ada Operator", - null, - null, - null, - null, - null, - null), - new RecordingExecutionGate()); + application, + new RecordingExecutionGate(), + outcome); Result result = - await handler.HandleAsync(Query(), CancellationToken.None); + await handler.HandleAsync( + Query(application.Version), + CancellationToken.None); Assert.Equal( WorkspaceStaffOnboardingApplicationErrors @@ -102,67 +143,199 @@ public async Task Reviewed_or_stale_target_is_not_disclosed( } [Fact] - public async Task Denied_execution_does_not_read_target() + public async Task Denied_execution_does_not_enter_read_boundary() { - RecordingTargetReader targets = new(null); - RecordingExecutionGate gate = new(allowed: false); + WorkspaceStaffOnboarding application = CreateApplication(); + RecordingReadBoundary boundary = new([]); GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQueryHandler - handler = new( - targets, - new WorkspaceStaffOnboardingDataRightsCorrectionAuthorizer( - gate, - new TestScopeContext())); + handler = CreateHandler( + application, + new RecordingExecutionGate(allowed: false), + boundary: boundary); Result result = - await handler.HandleAsync(Query(), CancellationToken.None); + await handler.HandleAsync( + Query(application.Version), + CancellationToken.None); Assert.Equal( WorkspaceStaffOnboardingApplicationErrors .DataRightsApprovalRequired, result.Error); - Assert.Equal(0, targets.ReadCount); + Assert.Equal(0, boundary.CallCount); } private static GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQueryHandler CreateHandler( - WorkspaceStaffOnboardingDataRightsCorrectionTarget target, - RecordingExecutionGate gate) => - new( - new RecordingTargetReader(target), + WorkspaceStaffOnboarding application, + RecordingExecutionGate gate, + StaffWorkspaceOnboardingIdentityAnchorOutcome? outcome = null, + List? calls = null, + RecordingReadBoundary? boundary = null) + { + calls ??= []; + ApplicationRepository applications = new(application); + return new( + WorkspaceStaffOnboardingMutationTestSupport.Create( + applications, + new RecordingOperationLock(calls)), + boundary ?? new RecordingReadBoundary(calls), + new StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader( + request => + { + calls.Add("staff"); + return outcome ?? + StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + .Absent(request); + }), new WorkspaceStaffOnboardingDataRightsCorrectionAuthorizer( gate, new TestScopeContext())); + } private static - GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQuery Query() => + GetWorkspaceStaffOnboardingDataRightsCorrectionTargetQuery Query( + long expectedVersion) => new( Guid.Parse("30000000-0000-0000-0000-000000000001"), Guid.Parse("40000000-0000-0000-0000-000000000001"), ApprovalRevision: 5, ApplicationId, - ExpectedVersion: 3, + expectedVersion, "user:privacy-owner"); - private sealed class RecordingTargetReader( - WorkspaceStaffOnboardingDataRightsCorrectionTarget? target) - : IWorkspaceStaffOnboardingDataRightsCorrectionTargetReader + private static WorkspaceStaffOnboarding CreateApplication() => + WorkspaceStaffOnboarding.Create( + ApplicationId, + TenantId, + WorkspaceStaffOnboardingSource.EnrollmentLink, + Guid.Parse("50000000-0000-0000-0000-000000000001"), + "subject:applicant", + "verified@example.test", + "Ada Operator", + "Ada Lovelace", + "ada@example.test", + "+1 555 0100", + "EMP-100", + "Manager", + "Operations", + Now).Value; + + private sealed class RecordingReadBoundary(List calls) + : IWorkspaceStaffOnboardingSerializedReadBoundary { - public int ReadCount { get; private set; } + public int CallCount { get; private set; } - public Task - GetAsync( - Guid applicationId, - CancellationToken cancellationToken) + public Task> RunAsync( + Func>> read, + CancellationToken cancellationToken) { - this.ReadCount++; - return Task.FromResult( - target?.ApplicationId == applicationId - ? target - : null); + this.CallCount++; + calls.Add("tenant"); + return read(cancellationToken); + } + } + + private sealed class RecordingOperationLock(List calls) + : IWorkspaceStaffOnboardingOperationLock + { + public Task AcquireSourceReadAsync( + Guid sourceId, + CancellationToken cancellationToken) + { + calls.Add("source"); + return Task.CompletedTask; + } + + public Task AcquireSourceWriteAsync( + Guid sourceId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task AcquireApplicantAsync( + Guid sourceId, + string subjectId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task TryAcquireAsync( + Guid applicationId, + CancellationToken cancellationToken) + { + calls.Add("application"); + return Task.FromResult(true); } } + private sealed class ApplicationRepository( + WorkspaceStaffOnboarding application) + : IWorkspaceStaffOnboardingRepository + { + public Task FindCoordinateAsync( + Guid applicationId, + CancellationToken cancellationToken) => + Task.FromResult( + application.Id == applicationId + ? new WorkspaceStaffOnboardingCoordinate( + application.Id, + application.SourceKind, + application.SourceId) + : null); + + public Task GetAsync( + Guid applicationId, + CancellationToken cancellationToken) => + Task.FromResult( + application.Id == applicationId ? application : null); + + public Task GetOperationalAsync( + Guid applicationId, + CancellationToken cancellationToken) => + this.GetAsync(applicationId, cancellationToken); + + public Task ReloadAsync( + WorkspaceStaffOnboarding candidate, + CancellationToken cancellationToken) => Task.CompletedTask; + + public Task + GetOperationalBySourceAndSubjectAsync( + WorkspaceStaffOnboardingSource sourceKind, + Guid sourceId, + string subjectId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task FindIdBySourceAndSubjectAsync( + WorkspaceStaffOnboardingSource sourceKind, + Guid sourceId, + string subjectId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetByClaimAsync( + Guid claimId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task> + ListActiveBySourceAsync( + WorkspaceStaffOnboardingSource sourceKind, + Guid sourceId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task ListActionableAsync( + PageRequest page, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task AddAsync( + WorkspaceStaffOnboarding candidate, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + 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/WorkspaceStaffAccessFlowTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffAccessFlowTests.cs index b789d986..35e357de 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffAccessFlowTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffAccessFlowTests.cs @@ -19,6 +19,10 @@ namespace BunkFy.Modules.Workspaces.Tests; using Gma.Modules.Organizations.Contracts; using Microsoft.Extensions.Logging.Abstractions; using Xunit; +using ContractRestorationDisposition = + BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffAccessRestorationDisposition; +using DomainRestorationDisposition = + BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessRestorationDisposition; [Trait("Category", "Unit")] public sealed class WorkspaceStaffAccessFlowTests @@ -40,10 +44,12 @@ public async Task Suspension_preparation_persists_the_exact_profile_snapshot() FakeProcessRepository repository = new(); PrepareWorkspaceStaffAccessCommandHandler handler = new( repository, + new FakeRestorationSuppressionReader(), WorkspaceStaffAccessMutationTestSupport.Create(repository), new WorkspaceAccessProvisioner(roles, profiles), WorkspaceOperationalAdmissionTestSupport.Allowed(ScopeId), - new TestClock()); + new TestClock(), + NullLogger.Instance); StaffLifecyclePolicyContext context = CreateContext( StaffLifecycleTransition.Suspend, StaffStatus.Active, @@ -56,8 +62,15 @@ public async Task Suspension_preparation_persists_the_exact_profile_snapshot() Assert.True(result.IsSuccess, result.Error.Code); Assert.True(result.Value.RequiresAccessDenial); + Assert.Equal( + ContractRestorationDisposition.NotApplicable, + result.Value.RestorationDisposition); + Assert.NotEqual(Guid.Empty, result.Value.ProcessId); WorkspaceStaffAccessProcess process = Assert.Single(repository.Processes); Assert.Equal(WorkspaceStaffAccessProcessState.Prepared, process.State); + Assert.Equal( + DomainRestorationDisposition.NotApplicable, + process.RestorationDisposition); Assert.Equal([custom.Id], process.ProfileSnapshots.Select(snapshot => snapshot.ProfileId)); } @@ -136,7 +149,7 @@ public async Task Owner_protection_keeps_profiles_and_blocks_the_staff_transitio } [Fact] - public async Task Resume_copies_the_latest_completed_suspension_snapshot_and_stays_denied() + public async Task Resume_after_completed_redacted_onboarding_copies_the_latest_completed_suspension_snapshot() { Guid first = Guid.NewGuid(); Guid second = Guid.NewGuid(); @@ -149,10 +162,12 @@ public async Task Resume_copies_the_latest_completed_suspension_snapshot_and_sta FakeProcessRepository repository = new(suspension); PrepareWorkspaceStaffAccessCommandHandler handler = new( repository, + new FakeRestorationSuppressionReader(), WorkspaceStaffAccessMutationTestSupport.Create(repository), new WorkspaceAccessProvisioner(new FakeRoles([]), new FakeProfiles([])), WorkspaceOperationalAdmissionTestSupport.Allowed(ScopeId), - new TestClock()); + new TestClock(), + NullLogger.Instance); StaffLifecyclePolicyContext context = CreateContext( StaffLifecycleTransition.Resume, StaffStatus.Suspended, @@ -165,13 +180,233 @@ public async Task Resume_copies_the_latest_completed_suspension_snapshot_and_sta Assert.True(result.IsSuccess, result.Error.Code); Assert.False(result.Value.RequiresAccessDenial); + Assert.Equal( + ContractRestorationDisposition.RestoreSnapshot, + result.Value.RestorationDisposition); + Assert.NotEqual(Guid.Empty, result.Value.ProcessId); WorkspaceStaffAccessProcess resume = repository.Processes.Single(process => process.Id != suspension.Id); Assert.Equal(WorkspaceStaffAccessProcessState.AwaitingStaffCommit, resume.State); + Assert.Equal( + DomainRestorationDisposition.RestoreSnapshot, + resume.RestorationDisposition); Assert.Equal( new[] { first, second }.Order(), resume.ProfileSnapshots.Select(snapshot => snapshot.ProfileId).Order()); } + [Fact] + public async Task Terminal_onboarding_resolution_persists_suppressed_resume_and_completes_without_access_work() + { + List operations = []; + Guid profileId = Guid.NewGuid(); + WorkspaceStaffAccessProcess suspension = CreateProcess( + WorkspaceStaffAccessTargetState.Suspended, + targetVersion: 2, + [profileId]); + Assert.True(suspension.MarkAwaitingStaffCommit(Now).IsSuccess); + Assert.True(suspension.ObserveStaffCommit(Now).IsSuccess); + FakeProcessRepository repository = new(suspension); + FakeRestorationSuppressionReader suppressions = new( + WorkspaceStaffOnboardingRestorationSuppressionState.Suppressed); + PrepareWorkspaceStaffAccessCommandHandler handler = new( + repository, + suppressions, + WorkspaceStaffAccessMutationTestSupport.Create(repository), + new WorkspaceAccessProvisioner( + new FakeRoles(operations), + new FakeProfiles(operations)), + WorkspaceOperationalAdmissionTestSupport.Allowed(ScopeId), + new TestClock(), + NullLogger.Instance); + StaffLifecyclePolicyContext context = CreateContext( + StaffLifecycleTransition.Resume, + StaffStatus.Suspended, + StaffStatus.Active, + expectedVersion: 2); + + Result result = await handler.HandleAsync( + new PrepareWorkspaceStaffAccessCommand(context), + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.False(result.Value.RequiresAccessDenial); + Assert.Equal( + ContractRestorationDisposition.Suppressed, + result.Value.RestorationDisposition); + Assert.NotEqual(Guid.Empty, result.Value.ProcessId); + WorkspaceStaffAccessProcess resume = repository.Processes.Single( + process => process.Id == result.Value.ProcessId); + Assert.Equal(WorkspaceStaffAccessTargetState.Active, resume.TargetState); + Assert.Equal( + WorkspaceStaffAccessProcessState.AwaitingStaffCommit, + resume.State); + Assert.Equal( + DomainRestorationDisposition.Suppressed, + resume.RestorationDisposition); + Assert.Empty(resume.ProfileSnapshots); + Assert.Equal(2, repository.Processes.Count); + Assert.Empty(operations); + Assert.Equal(1, suppressions.ReadCount); + + FakeMembershipLifecycle memberships = new(operations); + WorkspaceAccessProvisioner access = new( + new FakeRoles(operations), + new FakeProfiles(operations)); + WorkspaceStaffAccessRestorer restorer = new( + memberships, + access, + WorkspaceOperationalAdmissionTestSupport.Allowed(ScopeId), + new TestClock(), + NullLogger.Instance); + StaffLifecycleWorkspaceAccessHandler lifecycleHandler = new( + WorkspaceStaffAccessMutationTestSupport.Create(repository), + restorer, + new TestClock()); + + await lifecycleHandler.HandleAsync( + new StaffMemberLifecycleChangedIntegrationEvent( + Guid.NewGuid(), + ScopeId, + Now, + StaffId, + StaffStatus.Active, + new DateOnly(2026, 7, 21), + 3, + "user:owner"), + CancellationToken.None); + + Assert.Equal(WorkspaceStaffAccessProcessState.Completed, resume.State); + Assert.Empty(operations); + + WorkspaceStaffAccessDenier denier = new( + memberships, + access, + new TestClock(), + NullLogger.Instance); + RetryWorkspaceStaffAccessProcessCommandHandler retryHandler = new( + WorkspaceStaffAccessMutationTestSupport.Create(repository), + denier, + restorer); + + Result retry = await retryHandler.HandleAsync( + new RetryWorkspaceStaffAccessProcessCommand(resume.Id), + CancellationToken.None); + + Assert.True(retry.IsSuccess, retry.Error.Code); + Assert.Equal(WorkspaceStaffAccessProcessStatus.Completed, retry.Value.Status); + Assert.Equal( + ContractRestorationDisposition.Suppressed, + retry.Value.RestorationDisposition); + Assert.Empty(operations); + } + + [Fact] + public async Task Repeated_and_future_resume_attempts_remain_suppressed() + { + List operations = []; + FakeProcessRepository repository = new(); + FakeRestorationSuppressionReader suppressions = new( + WorkspaceStaffOnboardingRestorationSuppressionState.Suppressed); + PrepareWorkspaceStaffAccessCommandHandler handler = new( + repository, + suppressions, + WorkspaceStaffAccessMutationTestSupport.Create(repository), + new WorkspaceAccessProvisioner( + new FakeRoles(operations), + new FakeProfiles(operations)), + WorkspaceOperationalAdmissionTestSupport.Allowed(ScopeId), + new TestClock(), + NullLogger.Instance); + StaffLifecyclePolicyContext replay = CreateContext( + StaffLifecycleTransition.Resume, + StaffStatus.Suspended, + StaffStatus.Active, + expectedVersion: 2); + StaffLifecyclePolicyContext future = CreateContext( + StaffLifecycleTransition.Resume, + StaffStatus.Suspended, + StaffStatus.Active, + expectedVersion: 3); + + Result replayResult = await handler.HandleAsync( + new PrepareWorkspaceStaffAccessCommand(replay), + CancellationToken.None); + Result replayResultAgain = await handler.HandleAsync( + new PrepareWorkspaceStaffAccessCommand(replay), + CancellationToken.None); + + Assert.True(replayResult.IsSuccess, replayResult.Error.Code); + Assert.True(replayResultAgain.IsSuccess, replayResultAgain.Error.Code); + Assert.False(replayResult.Value.RequiresAccessDenial); + Assert.False(replayResultAgain.Value.RequiresAccessDenial); + Assert.Equal( + ContractRestorationDisposition.Suppressed, + replayResult.Value.RestorationDisposition); + Assert.Equal( + ContractRestorationDisposition.Suppressed, + replayResultAgain.Value.RestorationDisposition); + Assert.Equal(replayResult.Value.ProcessId, replayResultAgain.Value.ProcessId); + WorkspaceStaffAccessProcess first = Assert.Single(repository.Processes); + Assert.Equal( + DomainRestorationDisposition.Suppressed, + first.RestorationDisposition); + Assert.Equal(1, suppressions.ReadCount); + + Assert.True(first.ObserveStaffCommit(Now).IsSuccess); + Result futureResult = await handler.HandleAsync( + new PrepareWorkspaceStaffAccessCommand(future), + CancellationToken.None); + + Assert.True(futureResult.IsSuccess, futureResult.Error.Code); + Assert.False(futureResult.Value.RequiresAccessDenial); + Assert.Equal( + ContractRestorationDisposition.Suppressed, + futureResult.Value.RestorationDisposition); + Assert.NotEqual(first.Id, futureResult.Value.ProcessId); + WorkspaceStaffAccessProcess second = repository.Processes.Single( + process => process.Id == futureResult.Value.ProcessId); + Assert.Equal( + DomainRestorationDisposition.Suppressed, + second.RestorationDisposition); + Assert.Empty(second.ProfileSnapshots); + Assert.Equal(2, suppressions.ReadCount); + Assert.Empty(operations); + } + + [Theory] + [InlineData(WorkspaceStaffOnboardingRestorationSuppressionState.Conflict)] + [InlineData((WorkspaceStaffOnboardingRestorationSuppressionState)0)] + public async Task Non_authoritative_onboarding_resolution_ledger_fails_resume_closed( + WorkspaceStaffOnboardingRestorationSuppressionState suppressionState) + { + FakeProcessRepository repository = new(); + FakeRestorationSuppressionReader suppressions = new( + suppressionState); + PrepareWorkspaceStaffAccessCommandHandler handler = new( + repository, + suppressions, + WorkspaceStaffAccessMutationTestSupport.Create(repository), + new WorkspaceAccessProvisioner(new FakeRoles([]), new FakeProfiles([])), + WorkspaceOperationalAdmissionTestSupport.Allowed(ScopeId), + new TestClock(), + NullLogger.Instance); + StaffLifecyclePolicyContext context = CreateContext( + StaffLifecycleTransition.Resume, + StaffStatus.Suspended, + StaffStatus.Active, + expectedVersion: 2); + + Result result = await handler.HandleAsync( + new PrepareWorkspaceStaffAccessCommand(context), + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffAccessApplicationErrors.ProcessConflict, + result.Error); + Assert.Empty(repository.Processes); + Assert.Equal(1, suppressions.ReadCount); + } + [Fact] public async Task Unlinked_resume_does_not_restore_previous_subject_access() { @@ -215,10 +450,12 @@ public async Task Immediate_resume_uses_the_observed_suspension_without_a_stale_ }; PrepareWorkspaceStaffAccessCommandHandler handler = new( repository, + new FakeRestorationSuppressionReader(), WorkspaceStaffAccessMutationTestSupport.Create(repository), new WorkspaceAccessProvisioner(new FakeRoles([]), new FakeProfiles([])), WorkspaceOperationalAdmissionTestSupport.Allowed(ScopeId), - new TestClock()); + new TestClock(), + NullLogger.Instance); StaffLifecyclePolicyContext context = CreateContext( StaffLifecycleTransition.Resume, StaffStatus.Suspended, @@ -241,10 +478,12 @@ public async Task Restricted_workspace_blocks_staff_reactivation_before_process_ FakeProcessRepository repository = new(); PrepareWorkspaceStaffAccessCommandHandler handler = new( repository, + new FakeRestorationSuppressionReader(), WorkspaceStaffAccessMutationTestSupport.Create(repository), new WorkspaceAccessProvisioner(new FakeRoles([]), new FakeProfiles([])), WorkspaceOperationalAdmissionTestSupport.Restricted(ScopeId), - new TestClock()); + new TestClock(), + NullLogger.Instance); StaffLifecyclePolicyContext context = CreateContext( StaffLifecycleTransition.Resume, StaffStatus.Suspended, @@ -495,11 +734,6 @@ await prerequisite.PrepareAsync( Assert.Equal(1, correlations.ScrubCount); Assert.NotNull(correlations.Receipt); - profiles.Assign(subject, scope, profile.Id); - roles.Add( - subject, - WorkspaceAccessRoles.MembershipMarker, - scope); StaffRetentionAnonymisationPrerequisiteResult replay = await prerequisite.PrepareAsync( CreateRetentionRequest(), @@ -508,7 +742,7 @@ await prerequisite.PrepareAsync( Assert.Equal( StaffRetentionAnonymisationPrerequisiteStatus.Completed, replay.Status); - Assert.Equal(2, correlations.RequestCount); + Assert.Equal(1, correlations.RequestCount); Assert.Equal(1, correlations.ScrubCount); Assert.Empty(profiles.AssignedProfileIds(subject, scope)); Assert.False(roles.Has( @@ -516,11 +750,6 @@ await prerequisite.PrepareAsync( WorkspaceAccessRoles.MembershipMarker, scope)); - profiles.Assign(subject, scope, profile.Id); - roles.Add( - subject, - WorkspaceAccessRoles.MembershipMarker, - scope); StaffRetentionAnonymisationPrerequisiteResult verified = await prerequisite.VerifyAsync( CreateRetentionRequest(), @@ -529,15 +758,63 @@ await prerequisite.VerifyAsync( Assert.Equal( StaffRetentionAnonymisationPrerequisiteStatus.Completed, verified.Status); - Assert.Contains( - profile.Id, - profiles.AssignedProfileIds(subject, scope)); - Assert.True(roles.Has( + Assert.Empty(profiles.AssignedProfileIds(subject, scope)); + Assert.False(roles.Has( subject, WorkspaceAccessRoles.MembershipMarker, scope)); } + [Fact] + public async Task Staff_retention_blocks_divergent_existing_scrub_receipt() + { + FakeCorrelationRepository correlations = new(); + correlations.SeedReceipt( + WorkspaceStaffRetentionCorrelationReceipt.Create( + CorrelationReceiptId, + "different-tenant", + Guid.NewGuid(), + StaffId, + selectedStaffVersion: 2, + onboardingRecordsScrubbed: 1, + accessProcessRecordsScrubbed: 1, + accessPlanRecordsScrubbed: 1, + Now).Value); + List operations = []; + WorkspaceStaffAnonymisationAccessPrerequisite prerequisite = + CreateAccessPrerequisite( + new FakeStaffRestoreStateReader(new( + StaffId, + Version: 2, + StaffAnonymisationRestoreRecordState.Departed, + "member-a", + AnonymisedAtUtc: null)), + new FakeProcessRepository(), + new WorkspaceStaffAccessDenier( + new FakeMembershipLifecycle(operations), + new WorkspaceAccessProvisioner( + new FakeRoles(operations), + new FakeProfiles(operations)), + new TestClock(), + NullLogger.Instance), + correlations); + + StaffRetentionAnonymisationPrerequisiteResult result = + await prerequisite.PrepareAsync( + CreateRetentionRequest(), + CancellationToken.None); + + Assert.Equal( + StaffRetentionAnonymisationPrerequisiteStatus.Blocked, + result.Status); + Assert.Equal( + "Workspaces.StaffRetentionCorrelationReceiptInvalid", + result.OutcomeCode); + Assert.Equal(0, correlations.RequestCount); + Assert.Equal(0, correlations.ScrubCount); + Assert.Empty(operations); + } + [Fact] public async Task Staff_retention_blocks_without_durable_departure_mapping() { @@ -1188,6 +1465,27 @@ public Task AddAsync( } } + private sealed class FakeRestorationSuppressionReader( + WorkspaceStaffOnboardingRestorationSuppressionState state = + WorkspaceStaffOnboardingRestorationSuppressionState.None) + : IWorkspaceStaffOnboardingRestorationSuppressionReader + { + public int ReadCount { get; private set; } + + public Task ReadAsync( + string scopeId, + Guid staffMemberId, + string authSubjectId, + CancellationToken cancellationToken) + { + Assert.Equal(ScopeId, scopeId); + Assert.Equal(StaffId, staffMemberId); + Assert.Equal("member-a", authSubjectId); + this.ReadCount++; + return Task.FromResult(state); + } + } + private sealed class FakeMembershipLifecycle( List operations, OrganizationMembershipLifecycleOutcome outcome = OrganizationMembershipLifecycleOutcome.Changed) @@ -1236,6 +1534,10 @@ public WorkspaceStaffRetentionCorrelationReceipt? Receipt public int RequestCount { get; private set; } + public void SeedReceipt( + WorkspaceStaffRetentionCorrelationReceipt receipt) => + this.Receipt = receipt; + public Task GetAsync( Guid staffMemberId, diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffAccessMutationTestSupport.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffAccessMutationTestSupport.cs index 88a95b8c..6f52e19f 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffAccessMutationTestSupport.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffAccessMutationTestSupport.cs @@ -8,6 +8,9 @@ namespace BunkFy.Modules.Workspaces.Tests; internal static class WorkspaceStaffAccessMutationTestSupport { + public static IWorkspaceStaffAccessProcessRepository NoOpenProcesses { get; } = + new NoOpenProcessRepository(); + public static WorkspaceStaffAccessMutationCoordinator Create( IWorkspaceStaffAccessProcessRepository? processes = null, bool processExists = true, @@ -83,4 +86,47 @@ public Task AddAsync( CancellationToken cancellationToken) => throw new NotSupportedException(); } + + private sealed class NoOpenProcessRepository + : IWorkspaceStaffAccessProcessRepository + { + public Task GetAsync( + Guid processId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetByStaffVersionAsync( + Guid staffMemberId, + long targetStaffVersion, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetOpenByStaffAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + Task.FromResult(null); + + public Task + GetLatestCompletedSuspensionAsync( + Guid staffMemberId, + string subjectId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetCompletedDepartureAsync( + Guid staffMemberId, + long targetStaffVersion, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task ListOpenAsync( + PageRequest page, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task AddAsync( + WorkspaceStaffAccessProcess process, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } } diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffHistoricalNoProvisionCommandHandlerTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffHistoricalNoProvisionCommandHandlerTests.cs new file mode 100644 index 00000000..62b7d64d --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffHistoricalNoProvisionCommandHandlerTests.cs @@ -0,0 +1,756 @@ +namespace BunkFy.Modules.Workspaces.Tests.Application; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Handlers; +using BunkFy.Modules.Workspaces.Application.Models; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Persistence; +using BunkFy.Modules.Workspaces.Persistence.Repositories; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; +using Gma.Framework.Scoping; +using Gma.Modules.Organizations.Contracts; +using Microsoft.EntityFrameworkCore; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class WorkspaceStaffHistoricalNoProvisionCommandHandlerTests +{ + private const string TenantId = "aaaaaaaa-1111-1111-1111-111111111111"; + private const string OtherTenantId = + "bbbbbbbb-1111-1111-1111-111111111111"; + private static readonly Guid ApplicationId = + Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly Guid SourceId = + Guid.Parse("33333333-3333-3333-3333-333333333333"); + private static readonly Guid OperationId = + Guid.Parse("44444444-4444-4444-4444-444444444444"); + private static readonly Guid ReceiptId = + Guid.Parse("55555555-5555-5555-5555-555555555555"); + private static readonly Guid ManifestId = + Guid.Parse("66666666-6666-6666-6666-666666666666"); + private static readonly DateTimeOffset Now = new( + 2026, + 8, + 11, + 15, + 0, + 0, + TimeSpan.Zero); + private static readonly string EvidenceSha256 = new('a', 64); + + [Fact] + public async Task Exact_terminal_review_redacts_and_excludes_without_anchor_emission() + { + await using TestContext test = await CreateAsync( + sourceStatus: OrganizationInvitationStatus.Revoked, + terminalApplication: true); + + Result result = + await test.Handler.HandleAsync( + Command(test.ApplicationVersion, test.ApplicationStatus), + CancellationToken.None); + Assert.True(result.IsSuccess, result.Error.Code); + Assert.False(result.Value.AlreadyReviewed); + Assert.Equal(1, test.CrossGraph.AcquireCount); + Assert.Equal("subject:historical", test.Staff.LastExpectedSubjectId); + Assert.Equal(2, test.Organizations.SnapshotCalls); + + await test.DbContext.SaveChangesAsync(); + test.DbContext.ChangeTracker.Clear(); + WorkspaceStaffOnboarding application = await test.DbContext + .StaffOnboardingApplications.SingleAsync(); + WorkspaceStaffHistoricalNoProvisionReceipt receipt = await test + .DbContext.StaffHistoricalNoProvisionReceipts.SingleAsync(); + Assert.Equal( + WorkspaceStaffOnboardingState.Superseded, + application.Status); + Assert.Equal(test.ApplicationVersion + 1, application.Version); + Assert.Equal(receipt.CreateSubjectPseudonym(), application.SubjectId); + Assert.Null(application.VerifiedAccountEmail); + Assert.Null(application.DisplayName); + Assert.False(application.HasIdentityAnchorState); + Assert.True(receipt.HasValidCanonicalProof()); + Assert.DoesNotContain("subject:historical", receipt.CanonicalSha256); + Assert.Empty(application.DomainEvents); + Assert.Empty(receipt.DomainEvents); + + WorkspaceStaffIdentityAnchorCutoverSourceReader cutover = new( + test.DbContext); + Assert.Empty((await cutover.ListRelevantPageAsync( + afterApplicationId: null, + pageSize: 10, + CancellationToken.None)).Records); + + WorkspaceStaffIdentityAnchorSweepRepository sweep = new(test.DbContext); + Result page = await sweep + .PreparePageAsync( + TenantId, + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + batchSize: 10, + Now.AddMinutes(1), + CancellationToken.None); + Assert.True(page.IsSuccess, page.Error.Code); + Assert.Empty(page.Value.Candidates); + Assert.True(page.Value.ReachedEnd); + } + + [Fact] + public async Task Active_application_is_superseded_when_source_is_terminal() + { + await using TestContext test = await CreateAsync( + sourceStatus: OrganizationInvitationStatus.Expired, + terminalApplication: false); + + Result result = + await test.Handler.HandleAsync( + Command(test.ApplicationVersion, test.ApplicationStatus) with + { + ExpectedOrganizationsSourceStatus = + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationExpired + }, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal( + WorkspaceStaffOnboardingState.Superseded, + result.Value.ResultApplicationStatus); + Assert.Equal( + test.ApplicationVersion + 1, + result.Value.ResultApplicationVersion); + } + + [Theory] + [InlineData(OrganizationInvitationStatus.Pending)] + [InlineData(OrganizationInvitationStatus.Accepted)] + public async Task Current_organization_authority_is_blocked( + OrganizationInvitationStatus status) + { + await using TestContext test = await CreateAsync( + sourceStatus: status, + terminalApplication: true); + WorkspaceStaffHistoricalNoProvisionAuthorityStatus expected = status == + OrganizationInvitationStatus.Pending + ? WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationPending + : WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationAccepted; + + Result result = + await test.Handler.HandleAsync( + Command(test.ApplicationVersion, test.ApplicationStatus) with + { + ExpectedOrganizationsSourceStatus = expected + }, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffHistoricalNoProvisionApplicationErrors.Conflict, + result.Error); + Assert.Empty(test.DbContext.StaffHistoricalNoProvisionReceipts); + } + + [Theory] + [InlineData( + OrganizationEnrollmentLinkStatus.Disabled, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .EnrollmentLinkDisabled, + true)] + [InlineData( + OrganizationEnrollmentLinkStatus.Active, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .EnrollmentLinkActive, + false)] + [InlineData( + OrganizationEnrollmentLinkStatus.CapacityReached, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .EnrollmentLinkCapacityReached, + false)] + public async Task Enrollment_authority_uses_the_same_exact_terminal_gate( + OrganizationEnrollmentLinkStatus sourceStatus, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus expectedStatus, + bool succeeds) + { + await using TestContext test = await CreateAsync( + sourceStatus: OrganizationInvitationStatus.Revoked, + terminalApplication: true, + sourceKind: WorkspaceStaffOnboardingSource.EnrollmentLink, + enrollmentStatus: sourceStatus); + + Result result = + await test.Handler.HandleAsync( + Command(test.ApplicationVersion, test.ApplicationStatus) with + { + ExpectedOrganizationsSourceStatus = expectedStatus + }, + CancellationToken.None); + + Assert.Equal(succeeds, result.IsSuccess); + if (!succeeds) + { + Assert.Equal( + WorkspaceStaffHistoricalNoProvisionApplicationErrors.Conflict, + result.Error); + } + } + + [Theory] + [InlineData(false, StaffIdentityProvisioningAnchorCutoverDisposition.Ambiguous)] + [InlineData(true, StaffIdentityProvisioningAnchorCutoverDisposition.Conflict)] + public async Task Staff_non_absent_or_non_ambiguous_evidence_is_blocked( + bool absent, + StaffIdentityProvisioningAnchorCutoverDisposition disposition) + { + await using TestContext test = await CreateAsync( + sourceStatus: OrganizationInvitationStatus.Revoked, + terminalApplication: true, + absent, + disposition); + + Result result = + await test.Handler.HandleAsync( + Command(test.ApplicationVersion, test.ApplicationStatus), + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffHistoricalNoProvisionApplicationErrors.Conflict, + result.Error); + Assert.Equal(0, test.Organizations.ExportCalls); + Assert.Empty(test.DbContext.StaffHistoricalNoProvisionReceipts); + } + + [Fact] + public async Task Malformed_staff_outcome_batch_is_a_conflict() + { + await using TestContext test = await CreateAsync( + sourceStatus: OrganizationInvitationStatus.Revoked, + terminalApplication: true, + duplicateStaffOutcome: true); + + Result result = + await test.Handler.HandleAsync( + Command(test.ApplicationVersion, test.ApplicationStatus), + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffHistoricalNoProvisionApplicationErrors.Conflict, + result.Error); + Assert.Equal(0, test.Organizations.ExportCalls); + Assert.Empty(test.DbContext.StaffHistoricalNoProvisionReceipts); + } + + [Fact] + public async Task Exact_operation_replays_but_any_request_divergence_conflicts() + { + await using TestContext test = await CreateAsync( + sourceStatus: OrganizationInvitationStatus.Revoked, + terminalApplication: true); + ReviewWorkspaceStaffHistoricalNoProvisionCommand command = Command( + test.ApplicationVersion, + test.ApplicationStatus); + Result first = + await test.Handler.HandleAsync(command, CancellationToken.None); + Assert.True(first.IsSuccess, first.Error.Code); + await test.DbContext.SaveChangesAsync(); + + Result replay = + await test.Handler.HandleAsync(command, CancellationToken.None); + Assert.True(replay.IsSuccess, replay.Error.Code); + Assert.True(replay.Value.AlreadyReviewed); + Assert.Equal(first.Value.ReceiptId, replay.Value.ReceiptId); + Assert.Equal(1, test.Staff.OutcomeCalls); + Assert.Equal(1, test.Organizations.ExportCalls); + + Result conflict = + await test.Handler.HandleAsync( + command with { ExternalEvidenceSha256 = new string('b', 64) }, + CancellationToken.None); + Assert.Equal( + WorkspaceStaffHistoricalNoProvisionApplicationErrors.Conflict, + conflict.Error); + Assert.Single(test.DbContext.StaffHistoricalNoProvisionReceipts); + } + + [Fact] + public async Task Resurrected_or_anchored_result_is_neither_replayed_nor_excluded() + { + await using TestContext test = await CreateAsync( + sourceStatus: OrganizationInvitationStatus.Revoked, + terminalApplication: true); + ReviewWorkspaceStaffHistoricalNoProvisionCommand command = Command( + test.ApplicationVersion, + test.ApplicationStatus); + Result first = + await test.Handler.HandleAsync(command, CancellationToken.None); + Assert.True(first.IsSuccess, first.Error.Code); + await test.DbContext.SaveChangesAsync(); + test.DbContext.ChangeTracker.Clear(); + + WorkspaceStaffOnboarding application = await test.DbContext + .StaffOnboardingApplications.SingleAsync(); + test.DbContext.Entry(application) + .Property(candidate => candidate.DisplayName) + .CurrentValue = "resurrected profile"; + test.DbContext.Entry(application) + .Property(candidate => candidate.FailureCode) + .CurrentValue = "resurrected-failure"; + test.DbContext.Entry(application) + .Property(candidate => candidate.StaffMemberId) + .CurrentValue = Guid.NewGuid(); + await test.DbContext.SaveChangesAsync(); + test.DbContext.ChangeTracker.Clear(); + + Result replay = + await test.Handler.HandleAsync(command, CancellationToken.None); + Assert.Equal( + WorkspaceStaffHistoricalNoProvisionApplicationErrors.Conflict, + replay.Error); + + WorkspaceStaffIdentityAnchorCutoverSourceReader cutover = new( + test.DbContext); + WorkspaceStaffIdentityAnchorSourcePage sourcePage = await cutover + .ListRelevantPageAsync( + afterApplicationId: null, + pageSize: 10, + CancellationToken.None); + Assert.Equal( + ApplicationId, + Assert.Single(sourcePage.Records).ApplicationId); + + WorkspaceStaffIdentityAnchorSweepRepository sweep = new( + test.DbContext); + Result sweepPage = await sweep + .PreparePageAsync( + TenantId, + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + batchSize: 10, + Now.AddMinutes(1), + CancellationToken.None); + Assert.True(sweepPage.IsSuccess, sweepPage.Error.Code); + Assert.Equal( + ApplicationId, + Assert.Single(sweepPage.Value.Candidates).ApplicationId); + } + + [Fact] + public async Task Invalid_canonical_receipt_is_not_replayed() + { + await using TestContext test = await CreateAsync( + sourceStatus: OrganizationInvitationStatus.Revoked, + terminalApplication: true); + ReviewWorkspaceStaffHistoricalNoProvisionCommand command = Command( + test.ApplicationVersion, + test.ApplicationStatus); + Result first = + await test.Handler.HandleAsync(command, CancellationToken.None); + Assert.True(first.IsSuccess, first.Error.Code); + await test.DbContext.SaveChangesAsync(); + + WorkspaceStaffHistoricalNoProvisionReceipt receipt = await test + .DbContext.StaffHistoricalNoProvisionReceipts.SingleAsync(); + test.DbContext.Entry(receipt) + .Property(candidate => candidate.CanonicalSha256) + .CurrentValue = new string('c', 64); + + Result replay = + await test.Handler.HandleAsync(command, CancellationToken.None); + + Assert.Equal( + WorkspaceStaffHistoricalNoProvisionApplicationErrors.Conflict, + replay.Error); + } + + [Theory] + [InlineData(OrganizationEvidenceFailure.Missing)] + [InlineData(OrganizationEvidenceFailure.Duplicate)] + [InlineData(OrganizationEvidenceFailure.Stale)] + [InlineData(OrganizationEvidenceFailure.FinalRevisionDrift)] + public async Task Malformed_or_drifting_organization_evidence_fails_closed( + OrganizationEvidenceFailure failure) + { + await using TestContext test = await CreateAsync( + sourceStatus: OrganizationInvitationStatus.Revoked, + terminalApplication: true, + organizationFailure: failure); + + Result result = + await test.Handler.HandleAsync( + Command(test.ApplicationVersion, test.ApplicationStatus), + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffHistoricalNoProvisionApplicationErrors.Conflict, + result.Error); + Assert.Empty(test.DbContext.StaffHistoricalNoProvisionReceipts); + } + + [Fact] + public async Task Noncanonical_tenant_fails_before_any_lock_or_evidence_call() + { + await using TestContext test = await CreateAsync( + sourceStatus: OrganizationInvitationStatus.Revoked, + terminalApplication: true, + scopeId: TenantId.ToUpperInvariant()); + + Result result = + await test.Handler.HandleAsync( + Command(test.ApplicationVersion, test.ApplicationStatus), + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffHistoricalNoProvisionApplicationErrors + .TenantRequired, + result.Error); + Assert.Equal(0, test.CrossGraph.AcquireCount); + Assert.Equal(0, test.Staff.OutcomeCalls); + } + + [Fact] + public async Task Cross_tenant_scope_fails_closed_before_external_evidence() + { + await using TestContext test = await CreateAsync( + sourceStatus: OrganizationInvitationStatus.Revoked, + terminalApplication: true, + scopeId: OtherTenantId); + + Result result = + await test.Handler.HandleAsync( + Command(test.ApplicationVersion, test.ApplicationStatus), + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffHistoricalNoProvisionApplicationErrors.Conflict, + result.Error); + Assert.Equal(1, test.CrossGraph.AcquireCount); + Assert.Equal(0, test.Staff.OutcomeCalls); + Assert.Equal(0, test.Organizations.ExportCalls); + Assert.Empty(test.DbContext.StaffHistoricalNoProvisionReceipts); + } + + private static ReviewWorkspaceStaffHistoricalNoProvisionCommand Command( + long applicationVersion, + WorkspaceStaffOnboardingState applicationStatus) => + new( + OperationId, + ApplicationId, + applicationVersion, + applicationStatus, + ExpectedOrganizationsScopeRevision: 4, + ExpectedOrganizationsSourceVersion: 7, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationRevoked, + ManifestId, + EvidenceSha256, + ReviewerId: "operator:security-review"); + + private static async Task CreateAsync( + OrganizationInvitationStatus sourceStatus, + bool terminalApplication, + bool absent = true, + StaffIdentityProvisioningAnchorCutoverDisposition disposition = + StaffIdentityProvisioningAnchorCutoverDisposition.Ambiguous, + bool duplicateStaffOutcome = false, + OrganizationEvidenceFailure organizationFailure = + OrganizationEvidenceFailure.None, + WorkspaceStaffOnboardingSource sourceKind = + WorkspaceStaffOnboardingSource.Invitation, + OrganizationEnrollmentLinkStatus enrollmentStatus = + OrganizationEnrollmentLinkStatus.Disabled, + string scopeId = TenantId) + { + WorkspacesDbContext dbContext = new( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options, + new ScopeContext(TenantId)); + WorkspaceStaffOnboarding application = + WorkspaceStaffOnboarding.Create( + ApplicationId, + TenantId, + sourceKind, + SourceId, + "subject:historical", + "historical@example.test", + "Historical Person", + legalName: "Historical Person", + workEmail: null, + workPhone: null, + employeeNumber: null, + jobTitle: null, + department: null, + Now.AddMinutes(-10)).Value; + if (terminalApplication) + { + Assert.True(application.Supersede(Now.AddMinutes(-9)).IsSuccess); + } + + dbContext.StaffOnboardingApplications.Add(application); + dbContext.Entry(application) + .Property(candidate => candidate.IdentityAnchorSweepOrdinal) + .CurrentValue = 1; + await dbContext.SaveChangesAsync(); + long applicationVersion = application.Version; + WorkspaceStaffOnboardingState applicationStatus = application.Status; + dbContext.ChangeTracker.Clear(); + + WorkspaceStaffOnboardingRepository applications = new(dbContext); + RecordingOperationLock operationLock = new(); + RecordingWorkspaceCrossGraphMutationLock crossGraph = new(); + FakeStaffEvidence staff = new( + absent, + disposition, + duplicateStaffOutcome); + FakeOrganizations organizations = new( + sourceStatus, + organizationFailure, + enrollmentStatus); + WorkspaceStaffHistoricalNoProvisionReceiptRepository receipts = new( + dbContext); + ReviewWorkspaceStaffHistoricalNoProvisionCommandHandler handler = new( + crossGraph, + new WorkspaceStaffOnboardingMutationCoordinator( + operationLock, + applications), + receipts, + staff, + staff, + new WorkspaceStaffHistoricalNoProvisionAuthorityReader( + organizations), + new ScopeContext(scopeId), + new Clock(), + new Ids()); + return new( + dbContext, + handler, + crossGraph, + staff, + organizations, + applicationVersion, + applicationStatus); + } + + private sealed record TestContext( + WorkspacesDbContext DbContext, + ReviewWorkspaceStaffHistoricalNoProvisionCommandHandler Handler, + RecordingWorkspaceCrossGraphMutationLock CrossGraph, + FakeStaffEvidence Staff, + FakeOrganizations Organizations, + long ApplicationVersion, + WorkspaceStaffOnboardingState ApplicationStatus) : IAsyncDisposable + { + public ValueTask DisposeAsync() => this.DbContext.DisposeAsync(); + } + + private sealed class RecordingOperationLock + : IWorkspaceStaffOnboardingOperationLock + { + public Task AcquireSourceReadAsync( + Guid sourceId, + CancellationToken cancellationToken) => Task.CompletedTask; + + public Task AcquireSourceWriteAsync( + Guid sourceId, + CancellationToken cancellationToken) => Task.CompletedTask; + + public Task AcquireApplicantAsync( + Guid sourceId, + string subjectId, + CancellationToken cancellationToken) => Task.CompletedTask; + + public Task TryAcquireAsync( + Guid applicationId, + CancellationToken cancellationToken) => Task.FromResult(true); + } + + private sealed class FakeStaffEvidence( + bool absent, + StaffIdentityProvisioningAnchorCutoverDisposition disposition, + bool duplicateOutcome) + : IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader, + IStaffIdentityProvisioningAnchorCutover + { + public int OutcomeCalls { get; private set; } + public string? LastExpectedSubjectId { get; private set; } + + public Task> ReadAsync( + IReadOnlyList< + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest> + requests, + CancellationToken cancellationToken = default) + { + this.OutcomeCalls++; + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest request = + Assert.Single(requests); + this.LastExpectedSubjectId = request.ExpectedAuthSubjectId; + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome = absent + ? StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + .Absent(request) + : new( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Unresolved, + Guid.NewGuid(), + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + ResolutionEventId: Guid.NewGuid()); + return Task.FromResult>( + duplicateOutcome ? [outcome, outcome] : [outcome]); + } + + public Task InspectAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken = default) + { + StaffIdentityProvisioningAnchorCandidate candidate = Assert.Single( + candidates); + return Task.FromResult( + new StaffIdentityProvisioningAnchorInspection( + IsSuccess: true, + [new StaffIdentityProvisioningAnchorCandidateInspection( + candidate.SourceKind, + candidate.SourceId, + disposition)], + ErrorCode: null)); + } + + public Task ApplyAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Apply must not be called."); + } + + private sealed class FakeOrganizations( + OrganizationInvitationStatus status, + OrganizationEvidenceFailure failure, + OrganizationEnrollmentLinkStatus enrollmentStatus) + : IOrganizationScopeLifecycle + { + public int SnapshotCalls { get; private set; } + public int ExportCalls { get; private set; } + + public Task GetSnapshotAsync( + Guid organizationId, + CancellationToken cancellationToken) + { + this.SnapshotCalls++; + long revision = failure == + OrganizationEvidenceFailure.FinalRevisionDrift && + this.SnapshotCalls > 1 + ? 5 + : 4; + return Task.FromResult(new OrganizationScopeSnapshot( + OrganizationScopeStatus.Open, + revision)); + } + + public Task ExportAsync( + OrganizationScopeExportRequest request, + CancellationToken cancellationToken) + { + this.ExportCalls++; + OrganizationScopeExportRecord record = request.Store == + OrganizationScopeExportStore.EnrollmentLinks + ? new OrganizationScopeEnrollmentLinkExportRecord( + SourceId, + Guid.Parse(TenantId), + "subject:creator", + TokenVersion: 1, + Now.AddDays(-1), + MaximumClaims: 10, + ReservedClaims: 0, + OrganizationEnrollmentApprovalMode.Automatic, + enrollmentStatus, + Version: 7, + CreatedBy: "operator", + CreatedAtUtc: Now.AddYears(-1), + LastChangedBy: "operator", + LastChangedAtUtc: Now.AddDays(-1)) + : new OrganizationScopeInvitationExportRecord( + SourceId, + Guid.Parse(TenantId), + "subject:inviter", + RecipientEmail: null, + TokenVersion: 1, + Now.AddDays(-1), + status, + AcceptedSubjectId: null, + AcceptedMembershipId: null, + AcceptedAtUtc: null, + Version: 7, + CreatedBy: "operator", + CreatedAtUtc: Now.AddYears(-1), + LastChangedBy: "operator", + LastChangedAtUtc: Now.AddDays(-1)); + OrganizationScopeExportRecord[] records = failure switch + { + OrganizationEvidenceFailure.Missing => [], + OrganizationEvidenceFailure.Duplicate => [record, record], + _ => [record] + }; + Guid? finalId = records.Select(item => item switch + { + OrganizationScopeInvitationExportRecord invitation => + (Guid?)invitation.InvitationId, + OrganizationScopeEnrollmentLinkExportRecord link => + link.EnrollmentLinkId, + _ => null + }) + .LastOrDefault(); + return Task.FromResult(new OrganizationScopeExportPage( + failure == OrganizationEvidenceFailure.Stale + ? OrganizationScopeExportStatus.Stale + : OrganizationScopeExportStatus.Completed, + ScopeRevision: 4, + request.Store, + records, + finalId.HasValue ? "id:" + finalId.Value.ToString("D") : null, + HasMore: false)); + } + + public Task DestroyBatchAsync( + OrganizationScopeDestroyRequest request, + CancellationToken cancellationToken) => + throw new InvalidOperationException("Destroy must not be called."); + } + + private sealed class ScopeContext(string scopeId) : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => scopeId; + } + + private sealed class Clock : ISystemClock + { + public DateTimeOffset UtcNow => Now; + } + + private sealed class Ids : IIdGenerator + { + public Guid NewId() => ReceiptId; + } + + public enum OrganizationEvidenceFailure + { + None = 0, + Missing = 1, + Duplicate = 2, + Stale = 3, + FinalRevisionDrift = 4 + } +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffIdentityAnchorCutoverPagingTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffIdentityAnchorCutoverPagingTests.cs new file mode 100644 index 00000000..ccfb2721 --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffIdentityAnchorCutoverPagingTests.cs @@ -0,0 +1,630 @@ +namespace BunkFy.Modules.Workspaces.Tests; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Application.Handlers; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Results; +using Gma.Modules.Organizations.Contracts; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class WorkspaceStaffIdentityAnchorCutoverPagingTests +{ + private static readonly Guid OrganizationId = + Guid.Parse("1d68a3a4-e4eb-4bf6-b36b-b0b447965d02"); + private static readonly string TenantId = OrganizationId.ToString("D"); + + [Fact] + public async Task More_than_one_source_page_is_read_in_strict_order() + { + int count = WorkspaceStaffIdentityAnchorCutoverSourceLimits.PageSize + + 3; + WorkspaceStaffIdentityAnchorSourceRecord[] expected = Enumerable + .Range(1, count) + .Select(Source) + .OrderBy(record => record.ApplicationId) + .ToArray(); + KeysetSources sources = new(expected.Reverse()); + RecordingStaffCutover staff = new(); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = new( + sources, + staff, + new EmptyOrganizationScopeLifecycle()); + + Result result = + await coordinator.GetStatusAsync( + TenantId, + ownerManifest: null, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal(count, result.Value.WorkspaceSourceCount); + Assert.Equal( + [null, expected[199].ApplicationId], + sources.Requests.Select(request => request.AfterApplicationId)); + Assert.All( + sources.Requests, + request => Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverSourceLimits.PageSize, + request.PageSize)); + Assert.Equal( + expected.Select(record => record.ApplicationId), + staff.Inspections.SelectMany(batch => batch) + .Select(candidate => candidate.SourceId)); + Assert.All( + staff.Inspections, + batch => Assert.InRange( + batch.Count, + 1, + StaffWorkspaceOnboardingAnchorCutoverLimits + .MaximumBatchSize)); + } + + [Fact] + public async Task Non_advancing_source_page_fails_before_staff_inspection() + { + NonAdvancingSources sources = new(); + RecordingStaffCutover staff = new(); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = new( + sources, + staff, + new EmptyOrganizationScopeLifecycle()); + + Result result = + await coordinator.GetStatusAsync( + TenantId, + ownerManifest: null, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverErrors.SourcePageInvalid, + result.Error); + Assert.Equal(2, sources.RequestCount); + Assert.Single(staff.Inspections); + } + + [Fact] + public async Task Noncanonical_source_subject_fails_closed_before_Staff_inspection() + { + KeysetSources sources = new([ + Source(1) with { SubjectId = " subject:1 " } + ]); + RecordingStaffCutover staff = new(); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = new( + sources, + staff, + new EmptyOrganizationScopeLifecycle()); + + Result result = + await coordinator.GetStatusAsync( + TenantId, + ownerManifest: null, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverErrors.SourcePageInvalid, + result.Error); + Assert.Empty(staff.Inspections); + } + + [Fact] + public async Task Duplicate_source_coordinate_fails_closed() + { + WorkspaceStaffIdentityAnchorSourceRecord duplicate = Source(1); + RecordingStaffCutover staff = new(); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = new( + new KeysetSources([duplicate, duplicate]), + staff, + new EmptyOrganizationScopeLifecycle()); + + Result result = + await coordinator.GetStatusAsync( + TenantId, + ownerManifest: null, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverErrors.SourcePageInvalid, + result.Error); + Assert.Empty(staff.Inspections); + } + + [Fact] + public async Task More_than_ten_thousand_sources_stream_without_a_lifetime_cap() + { + const int count = 10_002; + GeneratedSources sources = new(count, terminalNullTargetSeed: count); + RecordingStaffCutover staff = new(retainInspections: false); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = new( + sources, + staff, + new EmptyOrganizationScopeLifecycle()); + + Result result = + await coordinator.GetStatusAsync( + TenantId, + ownerManifest: null, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal(count, result.Value.WorkspaceSourceCount); + Assert.Equal(count - 1, result.Value.SeedableWorkspaceCount); + Assert.Equal(1, result.Value.AmbiguousCount); + Assert.Equal(count, staff.InspectedCandidateCount); + Assert.InRange( + staff.MaximumInspectionSize, + 1, + StaffWorkspaceOnboardingAnchorCutoverLimits.MaximumBatchSize); + Assert.Equal(1, result.Value.TotalIssueCount); + Assert.Single(result.Value.Issues); + Assert.Equal( + CreateDeterministicGuid(count), + result.Value.Issues[0].SourceId); + } + + [Fact] + public async Task Full_stream_status_and_prepare_match_and_only_the_requested_batch_is_retained() + { + const int count = 10_001; + GeneratedSources sources = new(count); + RecordingStaffCutover staff = new(retainInspections: false); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = new( + sources, + staff, + new EmptyOrganizationScopeLifecycle()); + WorkspaceStaffIdentityAnchorOwnerManifest manifest = new( + 1, + TenantId, + new DateTimeOffset(2026, 8, 11, 12, 0, 0, TimeSpan.Zero), + new( + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + .ReviewedNoHistoricalOwnerSources, + new string('a', 64)), + []); + + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await coordinator.GetStatusAsync( + TenantId, + manifest, + CancellationToken.None)).Value; + Result prepared = + await coordinator.PrepareReconcileAsync( + TenantId, + status.SourceEvidenceSha256, + status.AnchorStateSha256, + manifest, + status.OwnerManifestSha256!, + StaffWorkspaceOnboardingAnchorCutoverLimits.MaximumBatchSize, + CancellationToken.None); + + Assert.True(prepared.IsSuccess, prepared.Error.Code); + Assert.Equal(status, prepared.Value.AcceptedStatus); + Assert.Equal(count, status.WorkspaceSourceCount); + Assert.Equal(count, status.SeedableWorkspaceCount); + Assert.Equal( + StaffWorkspaceOnboardingAnchorCutoverLimits.MaximumBatchSize, + prepared.Value.Batch.Count); + Assert.Equal( + Enumerable.Range( + 1, + StaffWorkspaceOnboardingAnchorCutoverLimits + .MaximumBatchSize) + .Select(CreateDeterministicGuid), + prepared.Value.Batch.Select(candidate => candidate.SourceId)); + Assert.Equal((long)count * 2, staff.InspectedCandidateCount); + } + + [Fact] + public async Task More_than_ten_thousand_memberships_are_revision_pinned_and_streamed_without_N_plus_one_reads() + { + const int count = 10_002; + GeneratedOrganizationScopeLifecycle organizations = new( + count, + activeOwnerSeed: count); + RecordingStaffCutover staff = new(retainInspections: false); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = new( + new GeneratedSources(count: 0), + staff, + organizations); + + Result result = + await coordinator.GetStatusAsync( + TenantId, + EmptyManifest(), + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal(1, result.Value.AuthoritativeOwnerCount); + Assert.Equal(1, result.Value.AmbiguousCount); + Assert.Equal(1, staff.InspectedCandidateCount); + Assert.Equal( + (int)Math.Ceiling( + count / + (double)OrganizationScopeLifecycleLimits.MaximumPageSize), + organizations.MembershipExportCallCount); + Assert.InRange( + organizations.MaximumMembershipPageCount, + 1, + OrganizationScopeLifecycleLimits.MaximumPageSize); + Assert.Equal(2, organizations.SnapshotCallCount); + } + + [Fact] + public async Task Organizations_revision_drift_during_membership_paging_fails_closed() + { + GeneratedOrganizationScopeLifecycle organizations = new( + count: OrganizationScopeLifecycleLimits.MaximumPageSize + 1, + activeOwnerSeed: null) + { + DriftOnMembershipExportCall = 2 + }; + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = new( + new GeneratedSources(count: 0), + new RecordingStaffCutover(retainInspections: false), + organizations); + + Result result = + await coordinator.GetStatusAsync( + TenantId, + EmptyManifest(), + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverErrors + .OrganizationsEvidenceChanged, + result.Error); + } + + private static WorkspaceStaffIdentityAnchorOwnerManifest EmptyManifest() => + new( + 1, + TenantId, + new DateTimeOffset(2026, 8, 11, 12, 0, 0, TimeSpan.Zero), + new( + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + .ReviewedNoHistoricalOwnerSources, + new string('a', 64)), + []); + + private static WorkspaceStaffIdentityAnchorSourceRecord Source(int seed) => + new( + CreateDeterministicGuid(seed), + CreateDeterministicGuid(20_000 + seed), + $"subject:{seed}", + WorkspaceStaffOnboardingState.Completed); + + private static Guid CreateDeterministicGuid(int value) => + Guid.ParseExact( + $"00000000-0000-0000-0000-{value:D12}", + "D"); + + private sealed class KeysetSources( + IEnumerable records) + : IWorkspaceStaffIdentityAnchorCutoverSourceReader + { + private readonly WorkspaceStaffIdentityAnchorSourceRecord[] records = + records.OrderBy(record => record.ApplicationId).ToArray(); + + public List<(Guid? AfterApplicationId, int PageSize)> Requests + { get; } = []; + + public Task + ListRelevantPageAsync( + Guid? afterApplicationId, + int pageSize, + CancellationToken cancellationToken) + { + this.Requests.Add((afterApplicationId, pageSize)); + WorkspaceStaffIdentityAnchorSourceRecord[] loaded = this.records + .Where(record => !afterApplicationId.HasValue || + record.ApplicationId.CompareTo( + afterApplicationId.Value) > 0) + .Take(pageSize + 1) + .ToArray(); + bool hasMore = loaded.Length > pageSize; + WorkspaceStaffIdentityAnchorSourceRecord[] selected = loaded + .Take(pageSize) + .ToArray(); + return Task.FromResult(new WorkspaceStaffIdentityAnchorSourcePage( + Array.AsReadOnly(selected), + selected.Length == 0 + ? afterApplicationId + : selected[^1].ApplicationId, + hasMore)); + } + } + + private sealed class NonAdvancingSources + : IWorkspaceStaffIdentityAnchorCutoverSourceReader + { + public int RequestCount { get; private set; } + + public Task + ListRelevantPageAsync( + Guid? afterApplicationId, + int pageSize, + CancellationToken cancellationToken) + { + this.RequestCount++; + int firstSeed = afterApplicationId.HasValue ? pageSize + 1 : 1; + WorkspaceStaffIdentityAnchorSourceRecord[] records = Enumerable + .Range(firstSeed, pageSize) + .Select(Source) + .ToArray(); + return Task.FromResult(new WorkspaceStaffIdentityAnchorSourcePage( + records, + afterApplicationId ?? records[^1].ApplicationId, + HasMore: true)); + } + } + + private sealed class GeneratedSources( + int count, + int? terminalNullTargetSeed = null) + : IWorkspaceStaffIdentityAnchorCutoverSourceReader + { + public Task + ListRelevantPageAsync( + Guid? afterApplicationId, + int pageSize, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + int start = afterApplicationId.HasValue + ? ParseSeed(afterApplicationId.Value) + 1 + : 1; + int loadedCount = Math.Min(pageSize + 1, count - start + 1); + WorkspaceStaffIdentityAnchorSourceRecord[] loaded = loadedCount > 0 + ? Enumerable.Range(start, loadedCount) + .Select(seed => new WorkspaceStaffIdentityAnchorSourceRecord( + CreateDeterministicGuid(seed), + seed == terminalNullTargetSeed + ? null + : CreateDeterministicGuid(20_000 + seed), + $"subject:{seed}", + seed == terminalNullTargetSeed + ? WorkspaceStaffOnboardingState.Superseded + : WorkspaceStaffOnboardingState.Completed)) + .ToArray() + : []; + WorkspaceStaffIdentityAnchorSourceRecord[] selected = loaded + .Take(pageSize) + .ToArray(); + return Task.FromResult(new WorkspaceStaffIdentityAnchorSourcePage( + selected, + selected.Length == 0 + ? afterApplicationId + : selected[^1].ApplicationId, + loaded.Length > pageSize)); + } + + private static int ParseSeed(Guid id) => int.Parse( + id.ToString("D", null)[^12..], + System.Globalization.CultureInfo.InvariantCulture); + } + + private sealed class RecordingStaffCutover( + bool retainInspections = true) + : IStaffIdentityProvisioningAnchorCutover + { + public List> + Inspections + { get; } = []; + public long InspectedCandidateCount { get; private set; } + public int MaximumInspectionSize { get; private set; } + + public Task InspectAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken = default) + { + this.InspectedCandidateCount += candidates.Count; + this.MaximumInspectionSize = Math.Max( + this.MaximumInspectionSize, + candidates.Count); + if (retainInspections) + { + this.Inspections.Add(candidates.ToArray()); + } + + return Task.FromResult(new StaffIdentityProvisioningAnchorInspection( + true, + candidates.Select(candidate => + new StaffIdentityProvisioningAnchorCandidateInspection( + candidate.SourceKind, + candidate.SourceId, + candidate.StaffMemberId.HasValue + ? StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromWorkspace + : StaffIdentityProvisioningAnchorCutoverDisposition + .Ambiguous)) + .ToArray(), + ErrorCode: null)); + } + + public Task ApplyAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + } + + private sealed class EmptyOrganizationScopeLifecycle + : IOrganizationScopeLifecycle + { + private const long Revision = 7; + private static readonly DateTimeOffset Now = new( + 2026, + 8, + 11, + 12, + 0, + 0, + TimeSpan.Zero); + + public Task GetSnapshotAsync( + Guid organizationId, + CancellationToken cancellationToken) => + Task.FromResult(new OrganizationScopeSnapshot( + OrganizationScopeStatus.Open, + Revision)); + + public Task ExportAsync( + OrganizationScopeExportRequest request, + CancellationToken cancellationToken) + { + OrganizationScopeExportRecord[] records = + request.Store == OrganizationScopeExportStore.Organization + ? [ + new OrganizationScopeOrganizationExportRecord( + request.OrganizationId, + "Tenant", + "tenant", + OrganizationStatus.Active, + ActiveOwnerCount: 0, + Version: 3, + CreatedBy: "system:test", + CreatedAtUtc: Now, + LastChangedBy: "system:test", + LastChangedAtUtc: Now) + ] + : []; + return Task.FromResult(new OrganizationScopeExportPage( + OrganizationScopeExportStatus.Completed, + Revision, + request.Store, + records, + NextCursor: null, + HasMore: false)); + } + + public Task DestroyBatchAsync( + OrganizationScopeDestroyRequest request, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + + private sealed class GeneratedOrganizationScopeLifecycle( + int count, + int? activeOwnerSeed) : IOrganizationScopeLifecycle + { + private const long Revision = 11; + private static readonly DateTimeOffset Now = new( + 2026, + 8, + 11, + 12, + 0, + 0, + TimeSpan.Zero); + + public int MembershipExportCallCount { get; private set; } + public int MaximumMembershipPageCount { get; private set; } + public int SnapshotCallCount { get; private set; } + public int? DriftOnMembershipExportCall { get; init; } + + public Task GetSnapshotAsync( + Guid organizationId, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + this.SnapshotCallCount++; + return Task.FromResult(new OrganizationScopeSnapshot( + OrganizationScopeStatus.Open, + Revision)); + } + + public Task ExportAsync( + OrganizationScopeExportRequest request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (request.Store == OrganizationScopeExportStore.Organization) + { + return Task.FromResult(new OrganizationScopeExportPage( + OrganizationScopeExportStatus.Completed, + Revision, + request.Store, + [new OrganizationScopeOrganizationExportRecord( + request.OrganizationId, + "Tenant", + "tenant", + OrganizationStatus.Active, + activeOwnerSeed.HasValue ? 1 : 0, + Version: 3, + CreatedBy: "system:test", + CreatedAtUtc: Now, + LastChangedBy: "system:test", + LastChangedAtUtc: Now)], + "id:" + request.OrganizationId.ToString("D"), + HasMore: false)); + } + + this.MembershipExportCallCount++; + if (this.DriftOnMembershipExportCall == + this.MembershipExportCallCount) + { + return Task.FromResult(new OrganizationScopeExportPage( + OrganizationScopeExportStatus.Stale, + Revision + 1, + request.Store, + [], + request.AfterCursor, + HasMore: false)); + } + + int start = request.AfterCursor is null + ? 1 + : ParseSeed(Guid.ParseExact( + request.AfterCursor[3..], + "D")) + 1; + int loadedCount = Math.Min( + request.PageSize + 1, + count - start + 1); + OrganizationScopeMembershipExportRecord[] loaded = loadedCount > 0 + ? Enumerable.Range(start, loadedCount) + .Select(seed => + new OrganizationScopeMembershipExportRecord( + CreateDeterministicGuid(seed), + request.OrganizationId, + $"subject:membership:{seed}", + seed == activeOwnerSeed + ? OrganizationMembershipRole.Owner + : OrganizationMembershipRole.Member, + seed == activeOwnerSeed + ? OrganizationMembershipStatus.Active + : OrganizationMembershipStatus.Removed, + Version: 1, + CreatedBy: "system:test", + JoinedAtUtc: Now, + LastChangedBy: "system:test", + LastChangedAtUtc: Now)) + .ToArray() + : []; + OrganizationScopeMembershipExportRecord[] selected = loaded + .Take(request.PageSize) + .ToArray(); + this.MaximumMembershipPageCount = Math.Max( + this.MaximumMembershipPageCount, + selected.Length); + return Task.FromResult(new OrganizationScopeExportPage( + OrganizationScopeExportStatus.Completed, + Revision, + request.Store, + selected, + selected.Length == 0 + ? request.AfterCursor + : "id:" + selected[^1].MembershipId.ToString("D"), + loaded.Length > request.PageSize)); + } + + public Task DestroyBatchAsync( + OrganizationScopeDestroyRequest request, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + private static int ParseSeed(Guid id) => int.Parse( + id.ToString("D", null)[^12..], + System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffIdentityAnchorCutoverTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffIdentityAnchorCutoverTests.cs new file mode 100644 index 00000000..24227ac7 --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffIdentityAnchorCutoverTests.cs @@ -0,0 +1,1463 @@ +namespace BunkFy.Modules.Workspaces.Tests; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Handlers; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Results; +using Gma.Framework.Scoping; +using Gma.Modules.Organizations.Contracts; +using System.Text.Json; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class WorkspaceStaffIdentityAnchorCutoverTests +{ + private static readonly Guid OrganizationId = + Guid.Parse("1d68a3a4-e4eb-4bf6-b36b-b0b447965d02"); + private static readonly string TenantId = OrganizationId.ToString("D"); + private static readonly DateTimeOffset ReviewedAtUtc = + new(2026, 8, 11, 12, 0, 0, TimeSpan.Zero); + private static readonly string EvidenceSha256 = new('a', 64); + + [Fact] + public async Task Status_is_write_free_and_reports_separate_tenant_bound_source_and_state_digests() + { + Guid applicationId = Guid.NewGuid(); + FakeSources sources = new([Source(applicationId, Guid.NewGuid())]); + FakeStaffCutover staff = new(); + FakeOrganizations organizations = new(OrganizationId); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = Create( + sources, + staff, + organizations); + + WorkspaceStaffIdentityAnchorCutoverStatus first = + (await coordinator.GetStatusAsync( + TenantId, + ownerManifest: null, + CancellationToken.None)).Value; + staff.AnchoredSources.Add(( + StaffIdentityProvisioningAnchorSourceKind.WorkspaceOnboarding, + applicationId)); + WorkspaceStaffIdentityAnchorCutoverStatus second = + (await coordinator.GetStatusAsync( + TenantId, + ownerManifest: null, + CancellationToken.None)).Value; + + Assert.Equal(0, staff.ApplyCount); + Assert.Equal(first.SourceEvidenceSha256, second.SourceEvidenceSha256); + Assert.NotEqual(first.AnchorStateSha256, second.AnchorStateSha256); + Assert.Equal(1, first.SeedableWorkspaceCount); + Assert.Equal(1, second.AlreadyAnchoredCount); + + Guid otherOrganizationId = Guid.NewGuid(); + WorkspaceStaffIdentityAnchorCutoverStatus otherTenant = + (await Create( + new FakeSources([Source(applicationId, staff.LastTargetId)]), + new FakeStaffCutover(), + new FakeOrganizations(otherOrganizationId)) + .GetStatusAsync( + otherOrganizationId.ToString("D"), + ownerManifest: null, + CancellationToken.None)).Value; + Assert.NotEqual( + first.SourceEvidenceSha256, + otherTenant.SourceEvidenceSha256); + Assert.NotEqual(first.AnchorStateSha256, otherTenant.AnchorStateSha256); + } + + [Fact] + public async Task V3_digests_bind_subject_status_target_disposition_and_record_count() + { + Guid applicationId = Guid.NewGuid(); + Guid targetId = Guid.NewGuid(); + WorkspaceStaffIdentityAnchorSourceRecord baselineRecord = Source( + applicationId, + targetId); + + WorkspaceStaffIdentityAnchorCutoverStatus baseline = + await ReadStatusAsync([baselineRecord], new FakeStaffCutover()); + WorkspaceStaffIdentityAnchorCutoverStatus subjectChanged = + await ReadStatusAsync( + [baselineRecord with { SubjectId = "subject:changed" }], + new FakeStaffCutover()); + WorkspaceStaffIdentityAnchorCutoverStatus statusChanged = + await ReadStatusAsync( + [baselineRecord with + { + Status = WorkspaceStaffOnboardingState.Rejected + }], + new FakeStaffCutover()); + WorkspaceStaffIdentityAnchorCutoverStatus targetChanged = + await ReadStatusAsync( + [baselineRecord with { StaffMemberId = Guid.NewGuid() }], + new FakeStaffCutover()); + WorkspaceStaffIdentityAnchorCutoverStatus countChanged = + await ReadStatusAsync( + [baselineRecord, Source(Guid.NewGuid(), Guid.NewGuid())], + new FakeStaffCutover()); + FakeStaffCutover anchoredStaff = new(); + anchoredStaff.AnchoredSources.Add(( + StaffIdentityProvisioningAnchorSourceKind.WorkspaceOnboarding, + applicationId)); + WorkspaceStaffIdentityAnchorCutoverStatus dispositionChanged = + await ReadStatusAsync([baselineRecord], anchoredStaff); + Guid membershipId = Guid.NewGuid(); + WorkspaceStaffIdentityAnchorCutoverStatus membershipSubjectBaseline = + (await Create( + new FakeSources([]), + new FakeStaffCutover(), + CurrentOwner(membershipId, "subject:owner-a", 1)) + .GetStatusAsync( + TenantId, + EmptyManifest(), + CancellationToken.None)).Value; + WorkspaceStaffIdentityAnchorCutoverStatus membershipSubjectChanged = + (await Create( + new FakeSources([]), + new FakeStaffCutover(), + CurrentOwner(membershipId, "subject:owner-b", 1)) + .GetStatusAsync( + TenantId, + EmptyManifest(), + CancellationToken.None)).Value; + + Assert.NotEqual( + baseline.SourceEvidenceSha256, + subjectChanged.SourceEvidenceSha256); + Assert.NotEqual( + baseline.AnchorStateSha256, + subjectChanged.AnchorStateSha256); + Assert.NotEqual( + baseline.SourceEvidenceSha256, + statusChanged.SourceEvidenceSha256); + Assert.NotEqual( + baseline.SourceEvidenceSha256, + targetChanged.SourceEvidenceSha256); + Assert.NotEqual( + baseline.AnchorStateSha256, + targetChanged.AnchorStateSha256); + Assert.NotEqual( + baseline.SourceEvidenceSha256, + countChanged.SourceEvidenceSha256); + Assert.NotEqual( + baseline.AnchorStateSha256, + countChanged.AnchorStateSha256); + Assert.Equal( + baseline.SourceEvidenceSha256, + dispositionChanged.SourceEvidenceSha256); + Assert.NotEqual( + baseline.AnchorStateSha256, + dispositionChanged.AnchorStateSha256); + Assert.NotEqual( + membershipSubjectBaseline.SourceEvidenceSha256, + membershipSubjectChanged.SourceEvidenceSha256); + Assert.NotEqual( + membershipSubjectBaseline.AnchorStateSha256, + membershipSubjectChanged.AnchorStateSha256); + } + + [Fact] + public async Task Prepare_fails_closed_when_the_streamed_source_digest_changes() + { + Guid applicationId = Guid.NewGuid(); + FakeSources sources = new([Source(applicationId, Guid.NewGuid())]); + FakeStaffCutover staff = new(); + WorkspaceStaffIdentityAnchorOwnerManifest manifest = EmptyManifest(); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = Create( + sources, + staff, + new FakeOrganizations(OrganizationId)); + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await coordinator.GetStatusAsync( + TenantId, + manifest, + CancellationToken.None)).Value; + sources.Records[0] = sources.Records[0] with + { + SubjectId = "subject:changed" + }; + + Result result = + await coordinator.PrepareReconcileAsync( + TenantId, + status.SourceEvidenceSha256, + status.AnchorStateSha256, + manifest, + status.OwnerManifestSha256!, + batchSize: 100, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverErrors.SourceEvidenceChanged, + result.Error); + Assert.Equal(0, staff.ApplyCount); + } + + [Fact] + public async Task Reconcile_acquires_the_Workspaces_lock_and_requires_source_state_and_manifest_evidence() + { + List calls = []; + Guid applicationId = Guid.NewGuid(); + FakeSources sources = new( + [Source(applicationId, Guid.NewGuid())], + calls); + FakeStaffCutover staff = new(calls); + WorkspaceStaffIdentityAnchorOwnerManifest manifest = EmptyManifest(); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = Create( + sources, + staff, + new FakeOrganizations(OrganizationId)); + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await coordinator.GetStatusAsync( + TenantId, + manifest, + CancellationToken.None)).Value; + calls.Clear(); + ReconcileWorkspaceStaffIdentityAnchorsCommandHandler handler = new( + coordinator, + new RecordingCutoverExecutionBoundary(calls), + new RecordingWorkspaceCrossGraphMutationLock(calls), + staff, + new DelegateFreshStatusReader((prepared, token) => ReadFreshAsync( + coordinator, + staff, + manifest, + prepared, + token)), + new TestScopeContext()); + ReconcileWorkspaceStaffIdentityAnchorsCommand command = new( + status.SourceEvidenceSha256, + status.AnchorStateSha256, + manifest, + status.OwnerManifestSha256!, + 100); + + Result first = + await handler.HandleAsync(command, CancellationToken.None); + + Assert.True(first.IsSuccess, first.Error.Code); + Assert.True( + calls.IndexOf("boundary.released") < calls.IndexOf("apply")); + Assert.True( + calls.IndexOf("tenant-exclusive") < + calls.IndexOf("boundary.released")); + Assert.Equal(1, first.Value.AppliedCount); + Assert.True(first.Value.Status!.IsReady); + } + + [Fact] + public async Task Reconcile_rejects_changed_anchor_state_before_any_write() + { + Guid applicationId = Guid.NewGuid(); + FakeStaffCutover staff = new(); + WorkspaceStaffIdentityAnchorOwnerManifest manifest = EmptyManifest(); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = Create( + new FakeSources([Source(applicationId, Guid.NewGuid())]), + staff, + new FakeOrganizations(OrganizationId)); + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await coordinator.GetStatusAsync( + TenantId, + manifest, + CancellationToken.None)).Value; + staff.AnchoredSources.Add(( + StaffIdentityProvisioningAnchorSourceKind.WorkspaceOnboarding, + applicationId)); + + Result result = + await coordinator.PrepareReconcileAsync( + TenantId, + status.SourceEvidenceSha256, + status.AnchorStateSha256, + manifest, + status.OwnerManifestSha256!, + 100, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverErrors.AnchorStateChanged, + result.Error); + Assert.Equal(0, staff.ApplyCount); + } + + [Fact] + public async Task Reconcile_reports_unknown_and_reads_fresh_state_after_lost_apply_response() + { + Guid applicationId = Guid.NewGuid(); + FakeStaffCutover staff = new(); + FakeOrganizations organizations = new(OrganizationId); + WorkspaceStaffIdentityAnchorOwnerManifest manifest = EmptyManifest(); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = Create( + new FakeSources([Source(applicationId, Guid.NewGuid())]), + staff, + organizations); + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await coordinator.GetStatusAsync( + TenantId, + manifest, + CancellationToken.None)).Value; + using CancellationTokenSource cancellationSource = new(); + staff.AfterApply = () => + { + cancellationSource.Cancel(); + throw new OperationCanceledException(cancellationSource.Token); + }; + DelegateFreshStatusReader fresh = new((prepared, token) => + ReadFreshAsync( + coordinator, + staff, + manifest, + prepared, + token)); + ReconcileWorkspaceStaffIdentityAnchorsCommandHandler handler = new( + coordinator, + new RecordingCutoverExecutionBoundary([]), + new RecordingWorkspaceCrossGraphMutationLock([]), + staff, + fresh, + new TestScopeContext()); + + Result result = + await handler.HandleAsync( + new ReconcileWorkspaceStaffIdentityAnchorsCommand( + status.SourceEvidenceSha256, + status.AnchorStateSha256, + manifest, + status.OwnerManifestSha256!, + 100), + cancellationSource.Token); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Null(result.Value.AppliedCount); + Assert.True(result.Value.Status!.IsReady); + Assert.Equal( + WorkspaceStaffIdentityAnchorReconcileOutcome + .ApplyOutcomeUnknown, + result.Value.Outcome); + Assert.True(result.Value.MustRerunStatus); + Assert.Equal(1, staff.ApplyCount); + Assert.Equal(1, fresh.ReadCount); + Assert.False(fresh.LastCancellationToken.IsCancellationRequested); + Assert.Contains( + (StaffIdentityProvisioningAnchorSourceKind.WorkspaceOnboarding, + applicationId), + staff.AnchoredSources); + } + + [Fact] + public async Task Reconcile_does_not_apply_when_the_preflight_boundary_cannot_confirm_release() + { + Guid applicationId = Guid.NewGuid(); + FakeStaffCutover staff = new(); + WorkspaceStaffIdentityAnchorOwnerManifest manifest = EmptyManifest(); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = Create( + new FakeSources([Source(applicationId, Guid.NewGuid())]), + staff, + new FakeOrganizations(OrganizationId)); + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await coordinator.GetStatusAsync( + TenantId, + manifest, + CancellationToken.None)).Value; + DelegateFreshStatusReader fresh = new((prepared, token) => + ReadFreshAsync( + coordinator, + staff, + manifest, + prepared, + token)); + ReconcileWorkspaceStaffIdentityAnchorsCommandHandler handler = new( + coordinator, + new UncertainReleaseCutoverExecutionBoundary(), + new RecordingWorkspaceCrossGraphMutationLock([]), + staff, + fresh, + new TestScopeContext()); + + await Assert.ThrowsAsync(() => + handler.HandleAsync( + new ReconcileWorkspaceStaffIdentityAnchorsCommand( + status.SourceEvidenceSha256, + status.AnchorStateSha256, + manifest, + status.OwnerManifestSha256!, + 100), + CancellationToken.None)); + + Assert.Equal(0, staff.ApplyCount); + Assert.Equal(0, fresh.ReadCount); + } + + [Fact] + public async Task Reconcile_treats_a_malformed_success_as_unknown_without_retrying() + { + Guid applicationId = Guid.NewGuid(); + FakeStaffCutover staff = new() + { + ApplyResultOverride = new( + IsSuccess: true, + AppliedCount: 2, + AlreadyAnchoredCount: 0, + ErrorCode: null) + }; + WorkspaceStaffIdentityAnchorOwnerManifest manifest = EmptyManifest(); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = Create( + new FakeSources([Source(applicationId, Guid.NewGuid())]), + staff, + new FakeOrganizations(OrganizationId)); + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await coordinator.GetStatusAsync( + TenantId, + manifest, + CancellationToken.None)).Value; + DelegateFreshStatusReader fresh = new((prepared, token) => + ReadFreshAsync( + coordinator, + staff, + manifest, + prepared, + token)); + ReconcileWorkspaceStaffIdentityAnchorsCommandHandler handler = new( + coordinator, + new RecordingCutoverExecutionBoundary([]), + new RecordingWorkspaceCrossGraphMutationLock([]), + staff, + fresh, + new TestScopeContext()); + + Result result = + await handler.HandleAsync( + new ReconcileWorkspaceStaffIdentityAnchorsCommand( + status.SourceEvidenceSha256, + status.AnchorStateSha256, + manifest, + status.OwnerManifestSha256!, + 100), + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal( + WorkspaceStaffIdentityAnchorReconcileOutcome.ApplyOutcomeUnknown, + result.Value.Outcome); + Assert.Null(result.Value.AppliedCount); + Assert.True(result.Value.Status!.IsReady); + Assert.Equal(1, staff.ApplyCount); + Assert.Equal(1, fresh.ReadCount); + } + + [Fact] + public async Task Reconcile_keeps_a_known_apply_count_but_is_unknown_when_fresh_status_fails() + { + FakeStaffCutover staff = new(); + WorkspaceStaffIdentityAnchorOwnerManifest manifest = EmptyManifest(); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = Create( + new FakeSources([Source(Guid.NewGuid(), Guid.NewGuid())]), + staff, + new FakeOrganizations(OrganizationId)); + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await coordinator.GetStatusAsync( + TenantId, + manifest, + CancellationToken.None)).Value; + DelegateFreshStatusReader fresh = new((_, _) => Task.FromResult( + Result.Failure( + WorkspaceStaffIdentityAnchorCutoverErrors + .OrganizationsUnavailable))); + ReconcileWorkspaceStaffIdentityAnchorsCommandHandler handler = new( + coordinator, + new RecordingCutoverExecutionBoundary([]), + new RecordingWorkspaceCrossGraphMutationLock([]), + staff, + fresh, + new TestScopeContext()); + + Result result = + await handler.HandleAsync( + new ReconcileWorkspaceStaffIdentityAnchorsCommand( + status.SourceEvidenceSha256, + status.AnchorStateSha256, + manifest, + status.OwnerManifestSha256!, + 100), + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal(1, result.Value.AppliedCount); + Assert.Null(result.Value.Status); + Assert.Equal( + WorkspaceStaffIdentityAnchorReconcileOutcome.ApplyOutcomeUnknown, + result.Value.Outcome); + Assert.True(result.Value.MustRerunStatus); + Assert.Equal(1, staff.ApplyCount); + Assert.Equal(1, fresh.ReadCount); + } + + [Fact] + public async Task Reconcile_marks_source_drift_during_fresh_verification_unknown() + { + FakeSources sources = new([ + Source(Guid.NewGuid(), Guid.NewGuid()) + ]); + FakeStaffCutover staff = new(); + WorkspaceStaffIdentityAnchorOwnerManifest manifest = EmptyManifest(); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = Create( + sources, + staff, + new FakeOrganizations(OrganizationId)); + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await coordinator.GetStatusAsync( + TenantId, + manifest, + CancellationToken.None)).Value; + DelegateFreshStatusReader fresh = new(async (prepared, token) => + { + sources.Records.Add(Source(Guid.NewGuid(), Guid.NewGuid())); + return await ReadFreshAsync( + coordinator, + staff, + manifest, + prepared, + token); + }); + ReconcileWorkspaceStaffIdentityAnchorsCommandHandler handler = new( + coordinator, + new RecordingCutoverExecutionBoundary([]), + new RecordingWorkspaceCrossGraphMutationLock([]), + staff, + fresh, + new TestScopeContext()); + + Result result = + await handler.HandleAsync( + new ReconcileWorkspaceStaffIdentityAnchorsCommand( + status.SourceEvidenceSha256, + status.AnchorStateSha256, + manifest, + status.OwnerManifestSha256!, + 100), + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal( + WorkspaceStaffIdentityAnchorReconcileOutcome.ApplyOutcomeUnknown, + result.Value.Outcome); + Assert.Equal(1, result.Value.AppliedCount); + Assert.NotNull(result.Value.Status); + Assert.NotEqual( + result.Value.AcceptedSourceEvidenceSha256, + result.Value.Status.SourceEvidenceSha256); + Assert.Equal(1, staff.ApplyCount); + } + + [Fact] + public async Task Reconcile_requires_exact_batch_convergence_in_fresh_inspection() + { + FakeStaffCutover staff = new(); + WorkspaceStaffIdentityAnchorOwnerManifest manifest = EmptyManifest(); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = Create( + new FakeSources([Source(Guid.NewGuid(), Guid.NewGuid())]), + staff, + new FakeOrganizations(OrganizationId)); + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await coordinator.GetStatusAsync( + TenantId, + manifest, + CancellationToken.None)).Value; + DelegateFreshStatusReader fresh = new(async (prepared, token) => + { + Result rebuilt = + await coordinator.GetStatusAsync(TenantId, manifest, token); + return Result.Success( + new WorkspaceStaffIdentityAnchorFreshVerification( + rebuilt.Value, + new StaffIdentityProvisioningAnchorInspection( + true, + prepared.Batch.Select(candidate => new + StaffIdentityProvisioningAnchorCandidateInspection( + candidate.SourceKind, + candidate.SourceId, + StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromWorkspace)).ToArray(), + null))); + }); + ReconcileWorkspaceStaffIdentityAnchorsCommandHandler handler = new( + coordinator, + new RecordingCutoverExecutionBoundary([]), + new RecordingWorkspaceCrossGraphMutationLock([]), + staff, + fresh, + new TestScopeContext()); + + Result result = + await handler.HandleAsync( + new ReconcileWorkspaceStaffIdentityAnchorsCommand( + status.SourceEvidenceSha256, + status.AnchorStateSha256, + manifest, + status.OwnerManifestSha256!, + 100), + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal( + WorkspaceStaffIdentityAnchorReconcileOutcome.ApplyOutcomeUnknown, + result.Value.Outcome); + Assert.True(result.Value.Status!.IsReady); + Assert.Equal(1, staff.ApplyCount); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public async Task Status_rejects_malformed_or_source_incompatible_Staff_inspection( + int mode) + { + FakeStaffCutover staff = new() + { + InspectionOverride = candidates => mode switch + { + 0 => new( + true, + candidates.Select(candidate => new + StaffIdentityProvisioningAnchorCandidateInspection( + candidate.SourceKind, + candidate.SourceId, + StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromReviewedOwnerMap)).ToArray(), + null), + 1 => new( + true, + [ + new(candidates[0].SourceKind, candidates[0].SourceId, + StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromWorkspace), + new(candidates[0].SourceKind, candidates[0].SourceId, + StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromWorkspace) + ], + null), + 2 => new( + true, + candidates.Select(candidate => new + StaffIdentityProvisioningAnchorCandidateInspection( + candidate.SourceKind, + candidate.SourceId, + StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromWorkspace)).ToArray(), + "unexpected-success-error"), + 3 => new( + true, + [ + new(candidates[0].SourceKind, Guid.NewGuid(), + StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromWorkspace), + new(candidates[1].SourceKind, candidates[1].SourceId, + StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromWorkspace) + ], + null), + _ => new( + true, + candidates.Select(candidate => new + StaffIdentityProvisioningAnchorCandidateInspection( + candidate.SourceKind, + candidate.SourceId, + StaffIdentityProvisioningAnchorCutoverDisposition + .Unknown)).ToArray(), + null) + } + }; + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = Create( + new FakeSources([ + Source(Guid.NewGuid(), Guid.NewGuid()), + Source(Guid.NewGuid(), Guid.NewGuid()) + ]), + staff, + new FakeOrganizations(OrganizationId)); + + Result result = + await coordinator.GetStatusAsync( + TenantId, + EmptyManifest(), + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverErrors.StaffUnavailable, + result.Error); + Assert.Equal(0, staff.ApplyCount); + } + + [Fact] + public async Task Reconcile_rejects_noncanonical_tenant_scope_before_boundary_or_lock() + { + List calls = []; + FakeStaffCutover staff = new(calls); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = Create( + new FakeSources([], calls), + staff, + new FakeOrganizations(OrganizationId)); + DelegateFreshStatusReader fresh = new((_, _) => throw new + InvalidOperationException("Fresh status must not run.")); + ReconcileWorkspaceStaffIdentityAnchorsCommandHandler handler = new( + coordinator, + new RecordingCutoverExecutionBoundary(calls), + new RecordingWorkspaceCrossGraphMutationLock(calls), + staff, + fresh, + new TestScopeContext(TenantId.ToUpperInvariant())); + + Result result = + await handler.HandleAsync( + new ReconcileWorkspaceStaffIdentityAnchorsCommand( + new string('a', 64), + new string('b', 64), + EmptyManifest(), + new string('c', 64), + 100), + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverErrors.TenantRequired, + result.Error); + Assert.Empty(calls); + Assert.Equal(0, staff.ApplyCount); + Assert.Equal(0, fresh.ReadCount); + } + + [Fact] + public async Task Current_owner_universe_is_not_defined_by_an_empty_manifest() + { + Guid membershipId = Guid.NewGuid(); + FakeOrganizations organizations = new(OrganizationId); + organizations.AddMembership( + membershipId, + "subject:owner", + OrganizationMembershipRole.Owner, + OrganizationMembershipStatus.Active, + version: 7); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = Create( + new FakeSources([]), + new FakeStaffCutover(), + organizations); + + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await coordinator.GetStatusAsync( + TenantId, + EmptyManifest(), + CancellationToken.None)).Value; + + Assert.Equal(1, status.AuthoritativeOwnerCount); + Assert.Equal(1, status.AmbiguousCount); + Assert.False(status.CanReconcile); + Assert.Equal(1, organizations.MembershipExportCallCount); + } + + [Fact] + public async Task Existing_anchor_covers_a_current_owner_without_a_new_binding() + { + Guid membershipId = Guid.NewGuid(); + FakeOrganizations organizations = CurrentOwner( + membershipId, + "subject:owner", + version: 4); + FakeStaffCutover staff = new(); + staff.AnchoredSources.Add(( + StaffIdentityProvisioningAnchorSourceKind.OrganizationMembership, + membershipId)); + + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await Create( + new FakeSources([]), + staff, + organizations) + .GetStatusAsync( + TenantId, + EmptyManifest(), + CancellationToken.None)).Value; + + Assert.True(status.CanReconcile); + Assert.True(status.IsReady); + Assert.Equal(1, status.AlreadyAnchoredCount); + } + + [Fact] + public async Task Reviewed_current_owner_binding_is_rechecked_and_passes_subject_only_to_Staff() + { + Guid membershipId = Guid.NewGuid(); + Guid staffMemberId = Guid.NewGuid(); + FakeOrganizations organizations = CurrentOwner( + membershipId, + "subject:owner", + version: 7); + FakeStaffCutover staff = new(); + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await Create( + new FakeSources([]), + staff, + organizations) + .GetStatusAsync( + TenantId, + Manifest(CurrentBinding( + membershipId, + staffMemberId, + observedVersion: 7)), + CancellationToken.None)).Value; + + Assert.Equal(1, status.SeedableOwnerCount); + Assert.True(status.CanReconcile); + StaffIdentityProvisioningAnchorCandidate candidate = + Assert.Single(staff.LastCandidates); + Assert.Equal("subject:owner", candidate.ExpectedAuthSubjectId); + Assert.DoesNotContain( + "subject:owner", + status.SourceEvidenceSha256, + StringComparison.Ordinal); + } + + [Fact] + public async Task Historical_binding_uses_external_attestation_and_does_not_require_current_Owner_or_Active_state() + { + Guid membershipId = Guid.NewGuid(); + Guid staffMemberId = Guid.NewGuid(); + FakeOrganizations organizations = new(OrganizationId); + organizations.AddMembership( + membershipId, + "subject:former-owner", + OrganizationMembershipRole.Member, + OrganizationMembershipStatus.Removed, + version: 9); + WorkspaceStaffIdentityAnchorOwnerBinding binding = new( + OrganizationId, + TenantId, + membershipId, + staffMemberId, + 8, + WorkspaceStaffIdentityAnchorOwnerBindingEvidenceKind + .HistoricalOwnerExternalReview); + + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await Create( + new FakeSources([]), + new FakeStaffCutover(), + organizations) + .GetStatusAsync( + TenantId, + Manifest( + binding, + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + .ReviewedHistoricalOwnerUniverse), + CancellationToken.None)).Value; + + Assert.Equal(0, status.AuthoritativeOwnerCount); + Assert.Equal(1, status.HistoricalBindingCount); + Assert.Equal(1, status.SeedableOwnerCount); + Assert.True(status.CanReconcile); + } + + [Fact] + public async Task Stale_or_unretained_historical_binding_blocks_and_zero_data_attestation_cannot_claim_it() + { + WorkspaceStaffIdentityAnchorOwnerBinding historical = new( + OrganizationId, + TenantId, + Guid.NewGuid(), + Guid.NewGuid(), + 1, + WorkspaceStaffIdentityAnchorOwnerBindingEvidenceKind + .HistoricalOwnerExternalReview); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = Create( + new FakeSources([]), + new FakeStaffCutover(), + new FakeOrganizations(OrganizationId)); + + Result invalidZeroData = + await coordinator.GetStatusAsync( + TenantId, + Manifest( + historical, + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + .ReviewedNoHistoricalOwnerSources), + CancellationToken.None); + WorkspaceStaffIdentityAnchorCutoverStatus stale = + (await coordinator.GetStatusAsync( + TenantId, + Manifest( + historical, + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + .ReviewedHistoricalOwnerUniverse), + CancellationToken.None)).Value; + + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverErrors.OwnerManifestInvalid, + invalidZeroData.Error); + Assert.Equal(1, stale.ConflictCount); + Assert.False(stale.CanReconcile); + } + + [Fact] + public async Task Owner_snapshot_revision_drift_aborts_the_plan_instead_of_using_a_stale_universe() + { + Guid membershipId = Guid.NewGuid(); + FakeOrganizations organizations = CurrentOwner( + membershipId, + "subject:owner", + version: 3); + organizations.ClosedOnSnapshotCall = 2; + + Result result = + await Create( + new FakeSources([]), + new FakeStaffCutover(), + organizations) + .GetStatusAsync( + TenantId, + EmptyManifest(), + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverErrors + .OrganizationsEvidenceChanged, + result.Error); + } + + [Fact] + public async Task Noncanonical_Organizations_subject_fails_closed_without_Staff_inspection() + { + FakeOrganizations organizations = CurrentOwner( + Guid.NewGuid(), + " subject:owner ", + version: 1); + FakeStaffCutover staff = new(); + + Result result = + await Create( + new FakeSources([]), + staff, + organizations) + .GetStatusAsync( + TenantId, + EmptyManifest(), + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverErrors + .OrganizationsUnavailable, + result.Error); + Assert.Empty(staff.LastCandidates); + } + + [Fact] + public async Task Reconcile_preflights_the_whole_tenant_and_writes_nothing_when_any_source_is_ambiguous() + { + FakeStaffCutover staff = new(); + WorkspaceStaffIdentityAnchorOwnerManifest manifest = EmptyManifest(); + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator = Create( + new FakeSources([ + Source(Guid.NewGuid(), Guid.NewGuid()), + Source(Guid.NewGuid(), null) + ]), + staff, + new FakeOrganizations(OrganizationId)); + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await coordinator.GetStatusAsync( + TenantId, + manifest, + CancellationToken.None)).Value; + + Result result = + await coordinator.PrepareReconcileAsync( + TenantId, + status.SourceEvidenceSha256, + status.AnchorStateSha256, + manifest, + status.OwnerManifestSha256!, + 100, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverErrors.Blocked, + result.Error); + Assert.Equal(0, staff.ApplyCount); + Assert.Equal(1, status.AmbiguousCount); + } + + [Fact] + public async Task Status_returns_bounded_deterministic_privacy_minimal_issue_coordinates() + { + WorkspaceStaffIdentityAnchorSourceRecord[] sources = Enumerable + .Range( + 1, + WorkspaceStaffIdentityAnchorCutoverStatusLimits.MaximumIssues + + 3) + .Select(value => Source(new Guid( + value, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0), null)) + .Reverse() + .ToArray(); + + WorkspaceStaffIdentityAnchorCutoverStatus status = + (await Create( + new FakeSources(sources), + new FakeStaffCutover(), + new FakeOrganizations(OrganizationId)) + .GetStatusAsync( + TenantId, + EmptyManifest(), + CancellationToken.None)).Value; + + Assert.Equal(sources.Length, status.TotalIssueCount); + Assert.True(status.HasMoreIssues); + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverStatusLimits.MaximumIssues, + status.Issues.Count); + Assert.Equal( + status.Issues.OrderBy(issue => issue.SourceKind) + .ThenBy(issue => issue.SourceId), + status.Issues); + Assert.All(status.Issues, issue => + { + Assert.Equal( + StaffIdentityProvisioningAnchorCutoverDisposition.Ambiguous, + issue.Disposition); + Assert.Null(issue.CandidateStaffMemberId); + Assert.Null(issue.ObservedMembershipVersion); + }); + string json = JsonSerializer.Serialize(status); + Assert.DoesNotContain("subject", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("profile", json, StringComparison.OrdinalIgnoreCase); + } + + private static WorkspaceStaffIdentityAnchorCutoverCoordinator Create( + FakeSources sources, + FakeStaffCutover staff, + FakeOrganizations organizations) => + new( + sources, + staff, + organizations); + + private static async Task + ReadStatusAsync( + IReadOnlyList records, + FakeStaffCutover staff) => + (await Create( + new FakeSources(records), + staff, + new FakeOrganizations(OrganizationId)) + .GetStatusAsync( + TenantId, + ownerManifest: null, + CancellationToken.None)).Value; + + private static async Task< + Result> ReadFreshAsync( + WorkspaceStaffIdentityAnchorCutoverCoordinator coordinator, + FakeStaffCutover staff, + WorkspaceStaffIdentityAnchorOwnerManifest manifest, + WorkspaceStaffIdentityAnchorPreparedReconcile prepared, + CancellationToken cancellationToken) + { + Result status = + await coordinator.GetStatusAsync( + TenantId, + manifest, + cancellationToken); + if (status.IsFailure) + { + return Result.Failure< + WorkspaceStaffIdentityAnchorFreshVerification>(status.Error); + } + + StaffIdentityProvisioningAnchorInspection inspection = + await staff.InspectAsync(prepared.Batch, cancellationToken); + return Result.Success( + new WorkspaceStaffIdentityAnchorFreshVerification( + status.Value, + inspection)); + } + + private static WorkspaceStaffIdentityAnchorSourceRecord Source( + Guid applicationId, + Guid? staffMemberId) => + new( + applicationId, + staffMemberId, + $"subject:{applicationId:N}", + staffMemberId.HasValue + ? WorkspaceStaffOnboardingState.Completed + : WorkspaceStaffOnboardingState.Failed); + + private static WorkspaceStaffIdentityAnchorOwnerManifest EmptyManifest() => + new( + 1, + TenantId, + ReviewedAtUtc, + new( + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + .ReviewedNoHistoricalOwnerSources, + EvidenceSha256), + []); + + private static WorkspaceStaffIdentityAnchorOwnerManifest Manifest( + WorkspaceStaffIdentityAnchorOwnerBinding binding, + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind evidenceKind = + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + .ReviewedNoHistoricalOwnerSources) => + new( + 1, + TenantId, + ReviewedAtUtc, + new(evidenceKind, EvidenceSha256), + [binding]); + + private static WorkspaceStaffIdentityAnchorOwnerBinding CurrentBinding( + Guid membershipId, + Guid staffMemberId, + long observedVersion) => + new( + OrganizationId, + TenantId, + membershipId, + staffMemberId, + observedVersion, + WorkspaceStaffIdentityAnchorOwnerBindingEvidenceKind + .CurrentActiveOwnerReview); + + private static FakeOrganizations CurrentOwner( + Guid membershipId, + string subjectId, + long version) + { + FakeOrganizations organizations = new(OrganizationId); + organizations.AddMembership( + membershipId, + subjectId, + OrganizationMembershipRole.Owner, + OrganizationMembershipStatus.Active, + version); + return organizations; + } + + private sealed class FakeSources( + IEnumerable records, + List? calls = null) + : IWorkspaceStaffIdentityAnchorCutoverSourceReader + { + public List Records { get; } = + [.. records]; + + public Task + ListRelevantPageAsync( + Guid? afterApplicationId, + int pageSize, + CancellationToken cancellationToken) + { + calls?.Add("source"); + WorkspaceStaffIdentityAnchorSourceRecord[] loaded = this.Records + .Where(record => !afterApplicationId.HasValue || + record.ApplicationId.CompareTo( + afterApplicationId.Value) > 0) + .OrderBy(record => record.ApplicationId) + .Take(pageSize + 1) + .ToArray(); + WorkspaceStaffIdentityAnchorSourceRecord[] selected = loaded + .Take(pageSize) + .ToArray(); + return Task.FromResult(new WorkspaceStaffIdentityAnchorSourcePage( + selected, + selected.Length == 0 + ? afterApplicationId + : selected[^1].ApplicationId, + loaded.Length > pageSize)); + } + } + + private sealed class FakeStaffCutover(List? calls = null) + : IStaffIdentityProvisioningAnchorCutover + { + public HashSet<(StaffIdentityProvisioningAnchorSourceKind, Guid)> + AnchoredSources + { get; } = []; + public int ApplyCount { get; private set; } + public Action? AfterApply { get; set; } + public StaffIdentityProvisioningAnchorApplyResult? ApplyResultOverride + { get; set; } + public Func, + StaffIdentityProvisioningAnchorInspection>? InspectionOverride + { get; set; } + public Guid? LastTargetId { get; private set; } + public IReadOnlyList + LastCandidates + { get; private set; } = []; + + public Task InspectAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken = default) + { + calls?.Add("inspect"); + this.LastCandidates = candidates.ToArray(); + this.LastTargetId = candidates.Count == 0 + ? null + : candidates[0].StaffMemberId; + if (this.InspectionOverride is not null) + { + return Task.FromResult(this.InspectionOverride(candidates)); + } + + return Task.FromResult(new StaffIdentityProvisioningAnchorInspection( + true, + candidates.Select(candidate => + new StaffIdentityProvisioningAnchorCandidateInspection( + candidate.SourceKind, + candidate.SourceId, + this.Disposition(candidate))) + .ToArray(), + null)); + } + + public Task ApplyAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken = default) + { + this.ApplyCount++; + calls?.Add("apply"); + int applied = candidates.Count(candidate => + this.AnchoredSources.Add( + (candidate.SourceKind, candidate.SourceId))); + this.AfterApply?.Invoke(); + return Task.FromResult( + this.ApplyResultOverride ?? + new StaffIdentityProvisioningAnchorApplyResult( + true, + applied, + candidates.Count - applied, + null)); + } + + private StaffIdentityProvisioningAnchorCutoverDisposition Disposition( + StaffIdentityProvisioningAnchorCandidate candidate) + { + if (this.AnchoredSources.Contains( + (candidate.SourceKind, candidate.SourceId))) + { + return StaffIdentityProvisioningAnchorCutoverDisposition + .AlreadyAnchored; + } + + if (!candidate.StaffMemberId.HasValue) + { + return StaffIdentityProvisioningAnchorCutoverDisposition + .Ambiguous; + } + + return candidate.SourceKind == + StaffIdentityProvisioningAnchorSourceKind.WorkspaceOnboarding + ? StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromWorkspace + : StaffIdentityProvisioningAnchorCutoverDisposition + .SeedableFromReviewedOwnerMap; + } + } + + private sealed class FakeOrganizations(Guid organizationId) + : IOrganizationScopeLifecycle + { + private readonly List + memberships = []; + + public long Revision { get; set; } = 1; + public int SnapshotCallCount { get; private set; } + public int MembershipExportCallCount { get; private set; } + public int? ClosedOnSnapshotCall { get; set; } + + public void AddMembership( + Guid membershipId, + string subjectId, + OrganizationMembershipRole role, + OrganizationMembershipStatus status, + long version) => this.memberships.Add(new( + membershipId, + organizationId, + subjectId, + role, + status, + version, + "system:test", + ReviewedAtUtc.AddDays(-1), + "system:test", + ReviewedAtUtc)); + + public Task GetSnapshotAsync( + Guid requestedOrganizationId, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + this.SnapshotCallCount++; + if (this.ClosedOnSnapshotCall == this.SnapshotCallCount) + { + return Task.FromResult(new OrganizationScopeSnapshot( + OrganizationScopeStatus.Closed, + this.Revision)); + } + + return Task.FromResult(requestedOrganizationId == organizationId + ? new OrganizationScopeSnapshot( + OrganizationScopeStatus.Open, + this.Revision) + : new OrganizationScopeSnapshot( + OrganizationScopeStatus.Missing, + 0)); + } + + public Task ExportAsync( + OrganizationScopeExportRequest request, + CancellationToken cancellationToken) + { + if (request.OrganizationId != organizationId || + request.ExpectedRevision != this.Revision) + { + return Task.FromResult(new OrganizationScopeExportPage( + OrganizationScopeExportStatus.Stale, + this.Revision, + request.Store, + [], + null, + false)); + } + + if (request.Store == OrganizationScopeExportStore.Organization) + { + int activeOwners = this.memberships.Count(membership => + membership.Role == OrganizationMembershipRole.Owner && + membership.Status == + OrganizationMembershipStatus.Active); + return Task.FromResult(new OrganizationScopeExportPage( + OrganizationScopeExportStatus.Completed, + this.Revision, + request.Store, + [new OrganizationScopeOrganizationExportRecord( + organizationId, + "Test organization", + "test-organization", + OrganizationStatus.Active, + activeOwners, + 1, + "system:test", + ReviewedAtUtc.AddDays(-1), + "system:test", + ReviewedAtUtc)], + "id:" + organizationId.ToString("D"), + false)); + } + + this.MembershipExportCallCount++; + + Guid? afterId = request.AfterCursor is null + ? null + : Guid.ParseExact(request.AfterCursor[3..], "D"); + OrganizationScopeMembershipExportRecord[] loaded = + this.memberships + .Where(membership => !afterId.HasValue || + membership.MembershipId.CompareTo(afterId.Value) > 0) + .OrderBy(membership => membership.MembershipId) + .Take(request.PageSize + 1) + .ToArray(); + OrganizationScopeMembershipExportRecord[] selected = loaded + .Take(request.PageSize) + .ToArray(); + return Task.FromResult(new OrganizationScopeExportPage( + OrganizationScopeExportStatus.Completed, + this.Revision, + request.Store, + selected, + selected.Length == 0 + ? request.AfterCursor + : "id:" + selected[^1].MembershipId.ToString("D"), + loaded.Length > request.PageSize)); + } + + public Task DestroyBatchAsync( + OrganizationScopeDestroyRequest request, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + + private sealed class RecordingWorkspaceCrossGraphMutationLock( + List calls) : IWorkspaceCrossGraphMutationLock + { + public Task AcquireAsync(CancellationToken cancellationToken) + { + calls.Add("tenant-exclusive"); + return Task.CompletedTask; + } + } + + private sealed class RecordingCutoverExecutionBoundary(List calls) + : IWorkspaceIdentityAnchorCutoverExecutionBoundary + { + public async Task> ExecuteAsync( + Func>> operation, + CancellationToken cancellationToken) + { + calls.Add("boundary.begin"); + Result result = await operation(cancellationToken); + calls.Add("boundary.released"); + return result; + } + } + + private sealed class UncertainReleaseCutoverExecutionBoundary + : IWorkspaceIdentityAnchorCutoverExecutionBoundary + { + public async Task> ExecuteAsync( + Func>> operation, + CancellationToken cancellationToken) + { + _ = await operation(cancellationToken); + throw new InvalidOperationException( + "The preflight transaction release is uncertain."); + } + } + + private sealed class DelegateFreshStatusReader( + Func>> read) + : IWorkspaceStaffIdentityAnchorFreshStatusReader + { + public int ReadCount { get; private set; } + public CancellationToken LastCancellationToken { get; private set; } + + public Task> + ReadAsync( + WorkspaceStaffIdentityAnchorPreparedReconcile prepared, + CancellationToken cancellationToken) + { + this.ReadCount++; + this.LastCancellationToken = cancellationToken; + return read(prepared, cancellationToken); + } + } + + private sealed class TestScopeContext(string? scopeId = null) : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId { get; } = scopeId ?? TenantId; + } +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffIdentityAnchorSweepRuntimeWiringTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffIdentityAnchorSweepRuntimeWiringTests.cs new file mode 100644 index 00000000..d7d19a0d --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffIdentityAnchorSweepRuntimeWiringTests.cs @@ -0,0 +1,306 @@ +namespace BunkFy.Modules.Workspaces.Tests.Application; + +using System.Runtime.CompilerServices; +using System.Text.Json; +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Handlers; +using BunkFy.Modules.Workspaces.Application.Models; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Application.Queries; +using BunkFy.Modules.Workspaces.Application.Tasks; +using BunkFy.Modules.Workspaces.Contracts; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Persistence; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; +using Gma.Framework.Scoping; +using Gma.Framework.Tasks; +using Gma.Framework.Tasks.Cqrs; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class WorkspaceStaffIdentityAnchorSweepRuntimeWiringTests +{ + private const string FirstTenantId = + "abcdef00-0000-0000-0000-000000000001"; + private const string SecondTenantId = + "abcdef00-0000-0000-0000-000000000002"; + + [Fact] + public void Descriptor_and_task_services_expose_the_same_runtime_contract() + { + ModuleTaskDescriptor descriptor = Assert.Single( + WorkspacesModuleMetadata.Descriptor.GetTasks(), + task => task.Name == + ReconcileWorkspaceStaffIdentityAnchorsPayload.TaskName); + Assert.Equal( + ReconcileWorkspaceStaffIdentityAnchorsPayload.WorkerGroup, + descriptor.WorkerGroup); + Assert.Equal(ModuleTaskKind.Recurring, descriptor.Kind); + Assert.Equal( + ReconcileWorkspaceStaffIdentityAnchorsPayload.PayloadVersion, + descriptor.PayloadVersion); + Assert.True(descriptor.IsScopeAware()); + + ServiceCollection services = new(); + services.AddSingleton(); + services.AddSingleton< + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader, + StubOutcomeReader>(); + services.AddSingleton< + IStaffWorkspaceOnboardingIdentityAnchorResolutionRecorder, + StubResolutionRecorder>(); + services.AddSingleton(); + services.AddSingleton( + new TestScopeContext(FirstTenantId)); + services.AddSingleton( + new StubSweepRepository([FirstTenantId])); + services.AddWorkspacesTaskHandlers(); + services.AddWorkspacesTaskHandlers(); + + using ServiceProvider provider = services.BuildServiceProvider(); + TaskHandlerRegistration registration = provider + .GetRequiredService() + .Find( + WorkspacesModuleMetadata.Name, + ReconcileWorkspaceStaffIdentityAnchorsPayload.TaskName, + ReconcileWorkspaceStaffIdentityAnchorsPayload.PayloadVersion)!; + Assert.Equal( + typeof(WorkspaceStaffIdentityAnchorSweepTaskHandler), + registration.HandlerType); + Assert.Equal(descriptor.WorkerGroup, registration.WorkerGroup); + Assert.True(registration.IsScopeAware()); + + using IServiceScope scope = provider.CreateScope(); + Assert.IsType( + scope.ServiceProvider.GetRequiredService( + registration.HandlerType)); + Assert.Single( + scope.ServiceProvider.GetServices(), + schedule => schedule is + WorkspaceStaffIdentityAnchorSweepScheduleProvider); + } + + [Fact] + public async Task Schedule_provider_emits_claimable_tenant_scoped_runs() + { + WorkspaceStaffIdentityAnchorSweepScheduleProvider provider = new( + new StubSweepRepository([FirstTenantId, SecondTenantId])); + + List schedules = []; + await foreach (ScheduledTaskDefinition schedule in provider + .GetSchedulesAsync(CancellationToken.None)) + { + schedules.Add(schedule); + } + + Assert.Equal( + [FirstTenantId, SecondTenantId], + schedules.Select(schedule => schedule.ScopeId)); + Assert.All(schedules, schedule => + { + Assert.Equal(WorkspacesModuleMetadata.Name, schedule.ModuleName); + Assert.Equal( + ReconcileWorkspaceStaffIdentityAnchorsPayload.TaskName, + schedule.TaskName); + Assert.Equal( + ReconcileWorkspaceStaffIdentityAnchorsPayload.WorkerGroup, + schedule.WorkerGroup); + Assert.Equal(TimeSpan.FromMinutes(5), schedule.Interval); + Assert.Equal(5, schedule.MaxAttempts); + Assert.True(schedule.RunOnStart); + ReconcileWorkspaceStaffIdentityAnchorsPayload payload = + JsonSerializer.Deserialize< + ReconcileWorkspaceStaffIdentityAnchorsPayload>( + schedule.PayloadJson)!; + Assert.Equal( + ReconcileWorkspaceStaffIdentityAnchorsPayload + .DefaultBatchSize, + payload.BatchSize); + Assert.Equal( + ReconcileWorkspaceStaffIdentityAnchorsPayload + .DefaultMaxBatches, + payload.MaxBatches); + }); + } + + [Fact] + public async Task Noncanonical_query_scope_fails_without_reading_status() + { + string noncanonical = FirstTenantId.ToUpperInvariant(); + StubSweepRepository repository = new([]); + GetWorkspaceStaffIdentityAnchorSweepStatusQueryHandler handler = new( + repository, + new TestScopeContext(noncanonical)); + + Result result = + await handler.HandleAsync( + new GetWorkspaceStaffIdentityAnchorSweepStatusQuery(), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors.ScopeRequired.Code, + result.Error.Code); + Assert.Equal(0, repository.StatusReadCount); + } + + [Fact] + public async Task Noncanonical_prepare_scope_fails_without_starting_cycle() + { + string noncanonical = FirstTenantId.ToUpperInvariant(); + StubSweepRepository repository = new([]); + RecordingWorkspaceCrossGraphMutationLock crossGraphLock = new(); + PrepareWorkspaceStaffIdentityAnchorSweepPageCommandHandler handler = + new( + repository, + crossGraphLock, + new TestScopeContext(noncanonical), + new TestClock()); + + Result result = + await handler.HandleAsync( + new( + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + ReconcileWorkspaceStaffIdentityAnchorsPayload + .DefaultBatchSize), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors.ScopeRequired.Code, + result.Error.Code); + Assert.Equal(0, repository.PrepareCount); + Assert.Equal(0, crossGraphLock.AcquireCount); + } + + [Fact] + public void Persistence_registers_repository_and_exposes_checkpoint_set() + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder([]); + builder.Configuration["Persistence:Provider"] = "PostgreSql"; + builder.Configuration["ConnectionStrings:PostgreSql"] = + "Host=localhost;Database=unused;Username=unused;Password=unused"; + + builder.AddWorkspacesPersistence(); + + ServiceDescriptor registration = Assert.Single( + builder.Services, + descriptor => descriptor.ServiceType == + typeof(IWorkspaceStaffIdentityAnchorSweepRepository)); + Assert.Equal( + "WorkspaceStaffIdentityAnchorSweepRepository", + registration.ImplementationType?.Name); + Assert.Equal(ServiceLifetime.Scoped, registration.Lifetime); + Assert.NotNull(typeof(WorkspacesDbContext).GetProperty( + nameof(WorkspacesDbContext.StaffIdentityAnchorSweepCheckpoints))); + } + + private sealed class StubSweepRepository( + IReadOnlyList scheduleScopes) + : IWorkspaceStaffIdentityAnchorSweepRepository + { + public int PrepareCount { get; private set; } + public int StatusReadCount { get; private set; } + + public Task> + PreparePageAsync( + string scopeId, + Guid checkpointId, + Guid cycleId, + Guid emptyAdvanceId, + Guid runId, + int batchSize, + DateTimeOffset nowUtc, + CancellationToken cancellationToken) + { + this.PrepareCount++; + throw new InvalidOperationException("Unexpected prepare."); + } + + public Task AdvanceAsync( + WorkspaceStaffIdentityAnchorSweepAdvance advance, + DateTimeOffset nowUtc, + CancellationToken cancellationToken) => + throw new InvalidOperationException("Unexpected advance."); + + public Task GetStatusAsync( + string scopeId, + CancellationToken cancellationToken) + { + this.StatusReadCount++; + throw new InvalidOperationException("Unexpected status read."); + } + + public async IAsyncEnumerable StreamScheduleScopeIdsAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + foreach (string scopeId in scheduleScopes) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + yield return scopeId; + } + } + } + + private sealed class StubTaskCommandDispatcher : ITaskCommandDispatcher + { + public Task> DispatchAsync( + TaskExecutionContext context, + TCommand command, + CancellationToken cancellationToken) + where TCommand : ICommand => + throw new InvalidOperationException("Unexpected dispatch."); + } + + private sealed class StubOutcomeReader + : IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + { + public Task> ReadAsync( + IReadOnlyList< + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest> + requests, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Unexpected outcome read."); + } + + private sealed class StubResolutionRecorder + : IStaffWorkspaceOnboardingIdentityAnchorResolutionRecorder + { + public Task + RecordAsync( + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest + request, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Unexpected record."); + } + + private sealed class StubIdGenerator : IIdGenerator + { + public Guid NewId() => Guid.NewGuid(); + } + + private sealed class TestScopeContext(string scopeId) : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId { get; } = scopeId; + } + + private sealed class TestClock : ISystemClock + { + public DateTimeOffset UtcNow { get; } = + new(2026, 8, 11, 12, 0, 0, TimeSpan.Zero); + } +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffIdentityAnchorSweepTaskHandlerTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffIdentityAnchorSweepTaskHandlerTests.cs new file mode 100644 index 00000000..70063ab8 --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffIdentityAnchorSweepTaskHandlerTests.cs @@ -0,0 +1,522 @@ +namespace BunkFy.Modules.Workspaces.Tests.Application; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Models; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Application.Tasks; +using BunkFy.Modules.Workspaces.Contracts; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Scoping; +using Gma.Framework.Tasks; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class WorkspaceStaffIdentityAnchorSweepTaskHandlerTests +{ + private const string TenantId = + "abcdef00-0000-0000-0000-000000000001"; + private static readonly DateTimeOffset Now = + new(2026, 8, 11, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task Passes_commit_before_record_and_observation_uses_a_fresh_dispatch() + { + Guid applicationId = Guid.NewGuid(); + Guid staffMemberId = Guid.NewGuid(); + Guid resolutionEventId = Guid.NewGuid(); + List calls = []; + WorkspaceStaffIdentityAnchorSweepPage page = Page( + new WorkspaceStaffIdentityAnchorSweepCandidate( + applicationId, + IdentityAnchorSweepOrdinal: 1, + "subject-1", + HasLocalAnchorState: true)); + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest request = new( + resolutionEventId, + applicationId, + staffMemberId, + WorkspaceApplicationVersion: 7, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + Now); + FakeTaskCommandDispatcher dispatcher = new( + page, + calls, + [ + Candidate( + applicationId, + WorkspaceStaffIdentityAnchorSweepCandidateOutcome + .PassOneCommitted), + Candidate( + applicationId, + WorkspaceStaffIdentityAnchorSweepCandidateOutcome + .ResolutionReadyToRecord, + request), + Candidate( + applicationId, + WorkspaceStaffIdentityAnchorSweepCandidateOutcome + .ObservedNow) + ]); + StubOutcomeReader reader = new( + calls, + requests => + [ + new( + requests[0].ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Unresolved, + staffMemberId, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + resolutionEventId) + ]); + RecordingRecorder recorder = new(calls); + WorkspaceStaffIdentityAnchorSweepTaskHandler handler = new( + dispatcher, + reader, + recorder, + new TestIdGenerator(), + new TestScopeContext(TenantId)); + + await handler.HandleAsync( + new ReconcileWorkspaceStaffIdentityAnchorsPayload(), + Context(), + CancellationToken.None); + + Assert.Equal( + [ + "prepare", + "batch-read", + "candidate:PassOneCommitted", + "candidate:ResolutionReadyToRecord", + "staff-record", + "candidate:ObservedNow", + "advance" + ], + calls); + Assert.Equal(request, Assert.Single(recorder.Requests)); + Assert.NotNull(dispatcher.Advance); + Assert.Equal(1, dispatcher.Advance.Counts.ScannedCount); + Assert.Equal(1, dispatcher.Advance.Counts.ObservedCount); + Assert.Equal(0, dispatcher.Advance.Counts.AlreadyObservedCount); + Assert.Equal(1, dispatcher.Advance.Counts.PassOneCommittedCount); + Assert.Equal( + 1, + dispatcher.Advance.Counts.ResolutionRecordConfirmedCount); + } + + [Fact] + public async Task Already_observed_is_not_reported_as_newly_settled() + { + Guid applicationId = Guid.NewGuid(); + List calls = []; + FakeTaskCommandDispatcher dispatcher = new( + Page(new WorkspaceStaffIdentityAnchorSweepCandidate( + applicationId, + IdentityAnchorSweepOrdinal: 1, + "subject-1", + HasLocalAnchorState: true)), + calls, + [Candidate( + applicationId, + WorkspaceStaffIdentityAnchorSweepCandidateOutcome + .AlreadyObserved)]); + StubOutcomeReader reader = new( + calls, + requests => + [Resolved(requests[0].ApplicationId)]); + RecordingRecorder recorder = new(calls); + WorkspaceStaffIdentityAnchorSweepTaskHandler handler = new( + dispatcher, + reader, + recorder, + new TestIdGenerator(), + new TestScopeContext(TenantId)); + + await handler.HandleAsync( + new ReconcileWorkspaceStaffIdentityAnchorsPayload(), + Context(), + CancellationToken.None); + + Assert.NotNull(dispatcher.Advance); + Assert.Equal(0, dispatcher.Advance.Counts.ObservedCount); + Assert.Equal(1, dispatcher.Advance.Counts.AlreadyObservedCount); + Assert.Empty(recorder.Requests); + } + + [Fact] + public async Task Absent_rows_are_skipped_but_local_contradictions_are_conflicts() + { + Guid cleanApplicationId = Guid.NewGuid(); + Guid contradictoryApplicationId = Guid.NewGuid(); + List calls = []; + WorkspaceStaffIdentityAnchorSweepPage page = Page( + new(cleanApplicationId, 1, "subject-clean", false), + new(contradictoryApplicationId, 2, "subject-local", true)); + FakeTaskCommandDispatcher dispatcher = new( + page, + calls, + [Candidate( + contradictoryApplicationId, + WorkspaceStaffIdentityAnchorSweepCandidateOutcome.NoAnchor)]); + StubOutcomeReader reader = new( + calls, + requests => requests.Select(request => Absent( + request.ApplicationId)) + .ToArray()); + WorkspaceStaffIdentityAnchorSweepTaskHandler handler = new( + dispatcher, + reader, + new RecordingRecorder(calls), + new TestIdGenerator(), + new TestScopeContext(TenantId)); + + await handler.HandleAsync( + new ReconcileWorkspaceStaffIdentityAnchorsPayload(), + Context(), + CancellationToken.None); + + Assert.Equal(1, dispatcher.CandidateDispatchCount); + Assert.NotNull(dispatcher.Advance); + Assert.Equal(1, dispatcher.Advance.Counts.NoAnchorCount); + Assert.Equal(1, dispatcher.Advance.Counts.ConflictCount); + } + + [Fact] + public async Task Duplicate_staff_keys_abort_before_any_candidate_transaction() + { + Guid firstId = Guid.NewGuid(); + Guid secondId = Guid.NewGuid(); + List calls = []; + FakeTaskCommandDispatcher dispatcher = new( + Page( + new(firstId, 1, "subject-1", true), + new(secondId, 2, "subject-2", true)), + calls, + []); + StubOutcomeReader reader = new( + calls, + _ => [Absent(firstId), Absent(firstId)]); + WorkspaceStaffIdentityAnchorSweepTaskHandler handler = new( + dispatcher, + reader, + new RecordingRecorder(calls), + new TestIdGenerator(), + new TestScopeContext(TenantId)); + + InvalidOperationException exception = + await Assert.ThrowsAsync(() => + handler.HandleAsync( + new ReconcileWorkspaceStaffIdentityAnchorsPayload(), + Context(), + CancellationToken.None)); + + Assert.Contains( + "Workspaces.IdentityAnchorSweepStaffBatchInvalid", + exception.Message, + StringComparison.Ordinal); + Assert.Equal(0, dispatcher.CandidateDispatchCount); + Assert.Null(dispatcher.Advance); + } + + [Fact] + public async Task Malformed_absent_cannot_be_counted_as_no_anchor() + { + Guid applicationId = Guid.NewGuid(); + List calls = []; + FakeTaskCommandDispatcher dispatcher = new( + Page(new WorkspaceStaffIdentityAnchorSweepCandidate( + applicationId, + IdentityAnchorSweepOrdinal: 1, + "subject-1", + HasLocalAnchorState: false)), + calls, + []); + StubOutcomeReader reader = new( + calls, + _ => + [ + new( + applicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Absent, + Guid.NewGuid(), + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + Guid.NewGuid()) + ]); + WorkspaceStaffIdentityAnchorSweepTaskHandler handler = new( + dispatcher, + reader, + new RecordingRecorder(calls), + new TestIdGenerator(), + new TestScopeContext(TenantId)); + + InvalidOperationException exception = + await Assert.ThrowsAsync(() => + handler.HandleAsync( + new ReconcileWorkspaceStaffIdentityAnchorsPayload(), + Context(), + CancellationToken.None)); + + Assert.Contains( + "Workspaces.IdentityAnchorSweepStaffBatchInvalid", + exception.Message, + StringComparison.Ordinal); + Assert.Equal(0, dispatcher.CandidateDispatchCount); + Assert.Null(dispatcher.Advance); + } + + [Fact] + public async Task Malformed_resolved_cannot_settle_the_bounded_cycle() + { + Guid applicationId = Guid.NewGuid(); + List calls = []; + FakeTaskCommandDispatcher dispatcher = new( + Page(new WorkspaceStaffIdentityAnchorSweepCandidate( + applicationId, + IdentityAnchorSweepOrdinal: 1, + "subject-1", + HasLocalAnchorState: true)), + calls, + []); + StubOutcomeReader reader = new( + calls, + _ => + [ + new( + applicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Resolved, + Guid.NewGuid(), + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + Guid.NewGuid()) + ]); + WorkspaceStaffIdentityAnchorSweepTaskHandler handler = new( + dispatcher, + reader, + new RecordingRecorder(calls), + new TestIdGenerator(), + new TestScopeContext(TenantId)); + + InvalidOperationException exception = + await Assert.ThrowsAsync(() => + handler.HandleAsync( + new ReconcileWorkspaceStaffIdentityAnchorsPayload(), + Context(), + CancellationToken.None)); + + Assert.Contains( + "Workspaces.IdentityAnchorSweepStaffBatchInvalid", + exception.Message, + StringComparison.Ordinal); + Assert.Equal(0, dispatcher.CandidateDispatchCount); + Assert.Null(dispatcher.Advance); + } + + [Fact] + public async Task Noncanonical_task_scope_is_rejected_before_dispatch() + { + string noncanonical = TenantId.ToUpperInvariant(); + List calls = []; + FakeTaskCommandDispatcher dispatcher = new( + Page(), + calls, + []); + WorkspaceStaffIdentityAnchorSweepTaskHandler handler = new( + dispatcher, + new StubOutcomeReader(calls, _ => []), + new RecordingRecorder(calls), + new TestIdGenerator(), + new TestScopeContext(noncanonical)); + + InvalidOperationException exception = + await Assert.ThrowsAsync(() => + handler.HandleAsync( + new ReconcileWorkspaceStaffIdentityAnchorsPayload(), + Context(noncanonical), + CancellationToken.None)); + + Assert.Contains( + "Workspaces.IdentityAnchorSweepTaskInvalid", + exception.Message, + StringComparison.Ordinal); + Assert.Empty(calls); + } + + private static WorkspaceStaffIdentityAnchorSweepCandidateResult Candidate( + Guid applicationId, + WorkspaceStaffIdentityAnchorSweepCandidateOutcome outcome, + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest? request = + null) => + new(applicationId, outcome, request); + + private static WorkspaceStaffIdentityAnchorSweepPage Page( + params WorkspaceStaffIdentityAnchorSweepCandidate[] candidates) => + new( + Guid.NewGuid(), + CheckpointVersion: 4, + Guid.NewGuid(), + UpperOrdinal: Math.Max(1, candidates.Length), + ExpectedAfterOrdinal: null, + candidates.Length == 0 + ? 1 + : candidates[^1].IdentityAnchorSweepOrdinal, + ReachedEnd: true, + AdvanceRequired: true, + Array.AsReadOnly(candidates)); + + private static StaffWorkspaceOnboardingIdentityAnchorOutcome Absent( + Guid applicationId) => + new( + applicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Absent, + StaffMemberId: null, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Unknown, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Unknown, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null); + + private static StaffWorkspaceOnboardingIdentityAnchorOutcome Resolved( + Guid applicationId) => + new( + applicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved, + Guid.NewGuid(), + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact, + WorkspaceApplicationVersion: 7, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + Guid.NewGuid()); + + private static TaskExecutionContext Context(string scopeId = TenantId) => + new( + Guid.NewGuid(), + WorkspacesModuleMetadata.Name, + ReconcileWorkspaceStaffIdentityAnchorsPayload.TaskName, + ReconcileWorkspaceStaffIdentityAnchorsPayload.WorkerGroup, + "worker-1", + "node-1", + attempt: 1, + scopeId: scopeId); + + private sealed class FakeTaskCommandDispatcher( + WorkspaceStaffIdentityAnchorSweepPage page, + List calls, + IEnumerable + candidates) + : IWorkspaceStaffIdentityAnchorSweepTransactionDispatcher + { + private readonly Queue< + WorkspaceStaffIdentityAnchorSweepCandidateResult> candidates = + new(candidates); + + public int CandidateDispatchCount { get; private set; } + public WorkspaceStaffIdentityAnchorSweepAdvance? Advance + { + get; + private set; + } + + public Task> + PreparePageAsync( + TaskExecutionContext context, + PrepareWorkspaceStaffIdentityAnchorSweepPageCommand command, + CancellationToken cancellationToken) + { + calls.Add("prepare"); + return Task.FromResult(Result.Success(page)); + } + + public Task> + ReconcileCandidateAsync( + TaskExecutionContext context, + ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommand + command, + CancellationToken cancellationToken) + { + this.CandidateDispatchCount++; + WorkspaceStaffIdentityAnchorSweepCandidateResult result = + this.candidates.Dequeue(); + calls.Add($"candidate:{result.Outcome}"); + return Task.FromResult(Result.Success(result)); + } + + public Task> AdvanceAsync( + TaskExecutionContext context, + AdvanceWorkspaceStaffIdentityAnchorSweepCommand command, + CancellationToken cancellationToken) + { + this.Advance = command.Advance; + calls.Add("advance"); + return Task.FromResult(Result.Success(Unit.Value)); + } + } + + private sealed class StubOutcomeReader( + List calls, + Func, + IReadOnlyList> read) + : IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + { + public Task> ReadAsync( + IReadOnlyList< + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest> + requests, + CancellationToken cancellationToken = default) + { + calls.Add("batch-read"); + Assert.InRange(requests.Count, 1, 500); + return Task.FromResult(read(requests)); + } + } + + private sealed class RecordingRecorder(List calls) + : IStaffWorkspaceOnboardingIdentityAnchorResolutionRecorder + { + public List + Requests + { get; } = []; + + public Task + RecordAsync( + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest request, + CancellationToken cancellationToken = default) + { + calls.Add("staff-record"); + this.Requests.Add(request); + return Task.FromResult( + new StaffWorkspaceOnboardingIdentityAnchorResolutionResult( + StaffWorkspaceOnboardingIdentityAnchorResolutionStatus + .Recorded)); + } + } + + private sealed class TestIdGenerator : IIdGenerator + { + public Guid NewId() => Guid.NewGuid(); + } + + private sealed class TestScopeContext(string scopeId) : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId { get; } = scopeId; + } +} 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..249b1bfe 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 @@ -8,6 +8,7 @@ namespace BunkFy.Modules.Workspaces.Tests; using BunkFy.Modules.Workspaces.Contracts; using BunkFy.Modules.Workspaces.Domain; using BunkFy.Modules.Workspaces.Domain.DataRights; +using BunkFy.Modules.Workspaces.Domain.Events; using Gma.Framework.AccessControl; using Gma.Framework.Cqrs; using Gma.Framework.Cqrs.Infrastructure; @@ -28,16 +29,65 @@ 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() { WorkspaceStaffOnboarding application = WorkspaceStaffOnboardingTests.CreateApplication(); FakeRepository applications = new(application); FakeStaffProvisioner staff = new(); + List operationCalls = []; + FakeOperationLock operationLock = new(operationCalls); using ServiceProvider provider = CreateProvider( applications, staff, - new FakeAccessControl()); + new FakeAccessControl(), + operationLock: operationLock); WorkspaceStaffAccessPlan acceptedPlan = (await provider .GetRequiredService() .GetAsync(application.SourceId, CancellationToken.None))!; @@ -63,10 +113,46 @@ await handler.HandleAsync( CancellationToken.None); Assert.Equal(1, application.ClaimVersion); - Assert.Equal(WorkspaceStaffOnboardingState.Completed, application.Status); - Assert.Equal(WorkspaceStaffAccessPlanState.Expired, acceptedPlan.Status); + Assert.Equal(WorkspaceStaffOnboardingState.StaffReady, application.Status); + Assert.Equal(WorkspaceStaffAccessPlanState.Active, acceptedPlan.Status); Assert.Equal(1, staff.CallCount); Assert.Equal(application.Id, staff.LastRequest?.OperationId); + Assert.Null(application.DisplayName); + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent + continuation = Assert.Single(application.DomainEvents.OfType< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent>()); + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + continuationFact = new( + continuation.EventId, + continuation.ScopeId, + continuation.OccurredAtUtc, + continuation.ApplicationId, + continuation.StaffMemberId); + WorkspaceStaffOnboardingIdentityAnchorContinuationHandler continuationHandler = + provider.GetRequiredService< + WorkspaceStaffOnboardingIdentityAnchorContinuationHandler>(); + operationCalls.Clear(); + + await continuationHandler.HandleAsync( + continuationFact, + CancellationToken.None); + + Assert.Equal(WorkspaceStaffOnboardingState.Completed, application.Status); + Assert.Equal(WorkspaceStaffAccessPlanState.Expired, acceptedPlan.Status); + Assert.Single(application.DomainEvents.OfType< + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent>()); + long completedVersion = application.Version; + + await continuationHandler.HandleAsync( + continuationFact, + CancellationToken.None); + + Assert.Equal(completedVersion, application.Version); + Assert.Single(application.DomainEvents.OfType< + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent>()); + Assert.Equal( + ["source-write", "application", "source-write", "application"], + operationCalls); } [Fact] @@ -163,8 +249,9 @@ public async Task Staff_failure_never_grants_workspace_access( public async Task Access_failure_retries_without_duplicate_staff_and_then_redacts_application() { WorkspaceStaffOnboarding application = WorkspaceStaffOnboardingTests.CreateApplication(); + Guid claimId = Guid.NewGuid(); Assert.True(application.ObserveClaimAccepted( - Guid.NewGuid(), + claimId, 1, WorkspaceStaffOnboardingTests.Now.AddMinutes(1)).IsSuccess); FakeRepository applications = new(application); @@ -184,14 +271,35 @@ public async Task Access_failure_retries_without_duplicate_staff_and_then_redact Result first = await handler.HandleAsync( new RetryWorkspaceStaffOnboardingCommand(application.Id), CancellationToken.None); + Assert.True(first.IsSuccess, first.Error.Code); + Assert.Equal( + WorkspaceStaffOnboardingStatus.StaffReady, + first.Value.Status); Assert.Equal(WorkspaceStaffAccessPlanState.Active, retryPlan.Status); + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + continuation = CreateContinuationFact(application); + WorkspaceStaffOnboardingIdentityAnchorContinuationHandler + continuationHandler = provider.GetRequiredService< + WorkspaceStaffOnboardingIdentityAnchorContinuationHandler>(); + + await Assert.ThrowsAsync(() => + continuationHandler.HandleAsync( + continuation, + CancellationToken.None)); + + Assert.Equal(WorkspaceStaffOnboardingState.Failed, application.Status); + WorkspaceStaffOnboarding rolledBack = CreateRolledBackStaffReady( + application, + claimId, + staffMemberId); + applications.Replace(rolledBack); + application = rolledBack; access.FailAssignments = false; - Result retried = await handler.HandleAsync( - new RetryWorkspaceStaffOnboardingCommand(application.Id), + + await continuationHandler.HandleAsync( + continuation, CancellationToken.None); - Assert.True(first.IsFailure); - Assert.True(retried.IsSuccess, retried.Error.Code); Assert.Equal(1, staff.CallCount); Assert.Collection( access.AssignmentCalls, @@ -205,11 +313,444 @@ public async Task Access_failure_retries_without_duplicate_staff_and_then_redact WorkspaceAccessScopes.Create(WorkspaceStaffOnboardingTests.OrganizationId.ToString("D")), call.Scope); }); - Assert.Equal(WorkspaceStaffOnboardingStatus.Completed, retried.Value.Status); + Assert.Equal(WorkspaceStaffOnboardingState.Completed, application.Status); Assert.Equal(WorkspaceStaffAccessPlanState.Expired, retryPlan.Status); - Assert.Equal(staffMemberId, retried.Value.StaffMemberId); - Assert.Null(retried.Value.VerifiedAccountEmail); - Assert.Null(retried.Value.DisplayName); + Assert.Equal(staffMemberId, application.StaffMemberId); + Assert.Null(application.VerifiedAccountEmail); + Assert.Null(application.DisplayName); + Assert.Single(application.DomainEvents.OfType< + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent>()); + } + + [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); + FakeStaffProvisioner staff = new(); + using ServiceProvider provider = CreateProvider( + applications, + staff, + 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.StaffReady, application.Status); + Assert.Equal(WorkspaceStaffAccessPlanState.Active, plan.Status); + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + continuation = CreateContinuationFact(application); + + await provider.GetRequiredService< + WorkspaceStaffOnboardingIdentityAnchorContinuationHandler>() + .HandleAsync(continuation, CancellationToken.None); + + Assert.Equal(WorkspaceStaffOnboardingState.Completed, application.Status); + Assert.Equal(WorkspaceStaffAccessPlanState.Expired, plan.Status); + Assert.Equal(1, staff.CallCount); + Assert.Single(application.DomainEvents.OfType< + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent>()); + Assert.Null(application.DisplayName); + } + + [Fact] + public async Task Restriction_release_is_a_durable_trigger_after_continuation_delivery_exhausts() + { + WorkspaceStaffOnboarding application = + WorkspaceStaffOnboardingTests.CreateApplication(); + Assert.True(application.ObserveClaimAccepted( + Guid.NewGuid(), + 1, + WorkspaceStaffOnboardingTests.Now.AddMinutes(1)).IsSuccess); + FakeRepository applications = new(application); + FakeStaffProvisioner staff = new(); + FakeRestrictionProjectionRepository restrictions = new( + applications.Applications); + using ServiceProvider provider = CreateProvider( + applications, + staff, + new FakeAccessControl(), + restrictionProjections: restrictions); + WorkspaceStaffAccessPlan plan = (await provider + .GetRequiredService() + .GetAsync(application.SourceId, CancellationToken.None))!; + Assert.True(plan.ObserveSourceExpired( + WorkspaceStaffOnboardingTests.Now.AddMinutes(2), + WorkspaceStaffOnboardingTests.Now.AddMinutes(2)).IsSuccess); + + Result first = await provider + .GetRequiredService>() + .HandleAsync( + new RetryWorkspaceStaffOnboardingCommand(application.Id), + CancellationToken.None); + Assert.True(first.IsSuccess, first.Error.Code); + Assert.Equal(WorkspaceStaffOnboardingState.StaffReady, application.Status); + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + continuation = CreateContinuationFact(application); + WorkspaceStaffOnboardingProcessingRestrictionProjection projection = + restrictions.Get(application.Id); + Assert.True(projection.Apply( + expectedRevision: 0, + WorkspaceStaffOnboardingProcessingRestrictionContract.CurrentVersion, + WorkspaceStaffOnboardingTests.Now.AddMinutes(3)).IsSuccess); + + await Assert.ThrowsAsync(() => provider + .GetRequiredService< + WorkspaceStaffOnboardingIdentityAnchorContinuationHandler>() + .HandleAsync(continuation, CancellationToken.None)); + + Assert.Equal(WorkspaceStaffOnboardingState.StaffReady, application.Status); + Assert.Null(application.IdentityAnchorResolutionEventId); + Assert.Equal(WorkspaceStaffAccessPlanState.Active, plan.Status); + Assert.True(projection.Release( + expectedRevision: 1, + WorkspaceStaffOnboardingProcessingRestrictionContract.CurrentVersion, + WorkspaceStaffOnboardingTests.Now.AddMinutes(4)).IsSuccess); + + await provider.GetRequiredService< + WorkspaceStaffOnboardingProcessingRestrictionRecoveryHandler>() + .HandleAsync( + new WorkspaceStaffOnboardingProcessingRestrictionChangedIntegrationEvent( + Guid.NewGuid(), + application.ScopeId, + WorkspaceStaffOnboardingTests.Now.AddMinutes(4), + application.Id, + WorkspaceStaffOnboardingProcessingRestrictionContract + .CurrentVersion, + projection.Revision, + isRestricted: false), + CancellationToken.None); + + Assert.Equal(WorkspaceStaffOnboardingState.Completed, application.Status); + Assert.Equal(WorkspaceStaffAccessPlanState.Expired, plan.Status); + Assert.Equal(1, staff.CallCount); + Assert.Single(application.DomainEvents.OfType< + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent>()); + } + + [Fact] + public async Task Restriction_release_converges_a_target_bound_terminal_application() + { + WorkspaceStaffOnboarding application = + WorkspaceStaffOnboardingTests.CreateApplication(); + Assert.True(application.ObserveClaimAccepted( + Guid.NewGuid(), + 1, + WorkspaceStaffOnboardingTests.Now.AddMinutes(1)).IsSuccess); + FakeRepository applications = new(application); + FakeRestrictionProjectionRepository restrictions = new( + applications.Applications); + using ServiceProvider provider = CreateProvider( + applications, + new FakeStaffProvisioner(), + new FakeAccessControl(), + restrictionProjections: restrictions); + + Result first = await provider + .GetRequiredService>() + .HandleAsync( + new RetryWorkspaceStaffOnboardingCommand(application.Id), + CancellationToken.None); + Assert.True(first.IsSuccess, first.Error.Code); + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + continuation = CreateContinuationFact(application); + WorkspaceStaffOnboardingProcessingRestrictionProjection projection = + restrictions.Get(application.Id); + Assert.True(projection.Apply( + expectedRevision: 0, + WorkspaceStaffOnboardingProcessingRestrictionContract.CurrentVersion, + WorkspaceStaffOnboardingTests.Now.AddMinutes(3)).IsSuccess); + await Assert.ThrowsAsync(() => provider + .GetRequiredService< + WorkspaceStaffOnboardingIdentityAnchorContinuationHandler>() + .HandleAsync(continuation, CancellationToken.None)); + Assert.True(application.Supersede( + WorkspaceStaffOnboardingTests.Now.AddMinutes(4)).IsSuccess); + Assert.Null(application.IdentityAnchorResolutionEventId); + Assert.True(projection.Release( + expectedRevision: 1, + WorkspaceStaffOnboardingProcessingRestrictionContract.CurrentVersion, + WorkspaceStaffOnboardingTests.Now.AddMinutes(5)).IsSuccess); + + await provider.GetRequiredService< + WorkspaceStaffOnboardingProcessingRestrictionRecoveryHandler>() + .HandleAsync( + new WorkspaceStaffOnboardingProcessingRestrictionChangedIntegrationEvent( + Guid.NewGuid(), + application.ScopeId, + WorkspaceStaffOnboardingTests.Now.AddMinutes(5), + application.Id, + WorkspaceStaffOnboardingProcessingRestrictionContract + .CurrentVersion, + projection.Revision, + isRestricted: false), + CancellationToken.None); + + Assert.Equal(WorkspaceStaffOnboardingState.Superseded, application.Status); + Assert.Equal( + BunkFy.Modules.Workspaces.Domain + .WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted, + application.IdentityAnchorResolutionDisposition); + Assert.Single(application.DomainEvents.OfType< + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent>()); + } + + [Theory] + [InlineData( + "Staff.StaffSuspended", + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Suspended, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact)] + [InlineData( + "Staff.StaffDeparted", + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Departed, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact)] + [InlineData( + "Staff.StaffMemberNotFound", + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Anonymised, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Missing)] + public async Task Property_reconcile_failure_rereads_committed_lifecycle_before_marking_failed( + string errorCode, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle terminalLifecycle, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch terminalSubjectMatch) + { + WorkspaceStaffOnboarding application = + WorkspaceStaffOnboardingTests.CreateApplication(); + Assert.True(application.ObserveClaimAccepted( + Guid.NewGuid(), + 1, + WorkspaceStaffOnboardingTests.Now.AddMinutes(1)).IsSuccess); + Guid staffMemberId = Guid.NewGuid(); + Guid resolutionEventId = Guid.NewGuid(); + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle lifecycle = + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active; + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch subjectMatch = + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact; + FakeStaffPropertyProvisioner properties = new() + { + ErrorCode = errorCode, + BeforeResult = () => + { + lifecycle = terminalLifecycle; + subjectMatch = terminalSubjectMatch; + } + }; + StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader outcomes = new( + request => new StaffWorkspaceOnboardingIdentityAnchorOutcome( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved, + staffMemberId, + lifecycle, + subjectMatch, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + resolutionEventId)); + FakeAccessControl access = new(); + using ServiceProvider provider = CreateProvider( + new FakeRepository(application), + new FakeStaffProvisioner + { + StaffMemberId = staffMemberId, + ResolutionEventId = resolutionEventId + }, + access, + staffProperties: properties, + anchorOutcomes: outcomes); + + Result first = await provider + .GetRequiredService>() + .HandleAsync( + new RetryWorkspaceStaffOnboardingCommand(application.Id), + CancellationToken.None); + Assert.True(first.IsSuccess, first.Error.Code); + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + continuation = CreateContinuationFact(application); + + await provider.GetRequiredService< + WorkspaceStaffOnboardingIdentityAnchorContinuationHandler>() + .HandleAsync(continuation, CancellationToken.None); + + Assert.Equal(1, properties.CallCount); + Assert.Equal(WorkspaceStaffOnboardingState.Superseded, application.Status); + Assert.Null(application.FailureCode); + Assert.Equal( + BunkFy.Modules.Workspaces.Domain + .WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted, + application.IdentityAnchorResolutionDisposition); + Assert.Single(application.DomainEvents.OfType< + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent>()); + Assert.Equal(0, access.AssignmentCallCount); + } + + [Fact] + public async Task Property_reconcile_lifecycle_race_with_open_process_is_retryable_without_failed_state() + { + WorkspaceStaffOnboarding application = + WorkspaceStaffOnboardingTests.CreateApplication(); + Assert.True(application.ObserveClaimAccepted( + Guid.NewGuid(), + 1, + WorkspaceStaffOnboardingTests.Now.AddMinutes(1)).IsSuccess); + Guid staffMemberId = Guid.NewGuid(); + Guid resolutionEventId = Guid.NewGuid(); + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle lifecycle = + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active; + FakeStaffPropertyProvisioner properties = new() + { + ErrorCode = "Staff.StaffSuspended", + BeforeResult = () => lifecycle = + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Suspended + }; + StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader outcomes = new( + request => new StaffWorkspaceOnboardingIdentityAnchorOutcome( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved, + staffMemberId, + lifecycle, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + resolutionEventId)); + WorkspaceStaffAccessProcess open = WorkspaceStaffAccessProcess.Create( + Guid.NewGuid(), + application.ScopeId, + staffMemberId, + application.SubjectId, + WorkspaceStaffAccessTargetState.Suspended, + targetStaffVersion: 2, + DateOnly.FromDateTime( + WorkspaceStaffOnboardingTests.Now.UtcDateTime), + "integration:staff", + [], + WorkspaceStaffOnboardingTests.Now).Value; + SingleOpenAccessProcessRepository accessProcesses = new(open); + using ServiceProvider provider = CreateProvider( + new FakeRepository(application), + new FakeStaffProvisioner + { + StaffMemberId = staffMemberId, + ResolutionEventId = resolutionEventId + }, + new FakeAccessControl(), + staffProperties: properties, + anchorOutcomes: outcomes, + accessProcesses: accessProcesses); + + Result first = await provider + .GetRequiredService>() + .HandleAsync( + new RetryWorkspaceStaffOnboardingCommand(application.Id), + CancellationToken.None); + Assert.True(first.IsSuccess, first.Error.Code); + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + continuation = CreateContinuationFact(application); + + InvalidOperationException exception = await Assert.ThrowsAsync< + InvalidOperationException>(() => provider.GetRequiredService< + WorkspaceStaffOnboardingIdentityAnchorContinuationHandler>() + .HandleAsync(continuation, CancellationToken.None)); + + Assert.Contains( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorLifecycleTransitionPending.Code, + exception.Message, + StringComparison.Ordinal); + Assert.Equal(1, properties.CallCount); + Assert.Equal(WorkspaceStaffOnboardingState.StaffReady, application.Status); + Assert.Null(application.FailureCode); + Assert.Null(application.IdentityAnchorResolutionEventId); + } + + [Fact] + public async Task Property_reconcile_error_is_preserved_only_when_final_authority_remains_active() + { + WorkspaceStaffOnboarding application = + WorkspaceStaffOnboardingTests.CreateApplication(); + Assert.True(application.ObserveClaimAccepted( + Guid.NewGuid(), + 1, + WorkspaceStaffOnboardingTests.Now.AddMinutes(1)).IsSuccess); + Guid staffMemberId = Guid.NewGuid(); + Guid resolutionEventId = Guid.NewGuid(); + FakeStaffPropertyProvisioner properties = new() + { + ErrorCode = "Staff.StaffSuspended" + }; + StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader outcomes = new( + request => new StaffWorkspaceOnboardingIdentityAnchorOutcome( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved, + staffMemberId, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + resolutionEventId)); + using ServiceProvider provider = CreateProvider( + new FakeRepository(application), + new FakeStaffProvisioner + { + StaffMemberId = staffMemberId, + ResolutionEventId = resolutionEventId + }, + new FakeAccessControl(), + staffProperties: properties, + anchorOutcomes: outcomes); + + Result first = await provider + .GetRequiredService>() + .HandleAsync( + new RetryWorkspaceStaffOnboardingCommand(application.Id), + CancellationToken.None); + Assert.True(first.IsSuccess, first.Error.Code); + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + continuation = CreateContinuationFact(application); + + await Assert.ThrowsAsync(() => provider + .GetRequiredService< + WorkspaceStaffOnboardingIdentityAnchorContinuationHandler>() + .HandleAsync(continuation, CancellationToken.None)); + + Assert.Equal(1, properties.CallCount); + Assert.Equal(WorkspaceStaffOnboardingState.Failed, application.Status); + Assert.Equal("Staff.StaffSuspended", application.FailureCode); + Assert.Null(application.IdentityAnchorResolutionEventId); } private static void AssertProvisionerAssignment( @@ -260,6 +801,226 @@ public async Task Submission_derives_workspace_authority_from_the_token_and_veri provider.GetRequiredService().ScopeId); } + [Fact] + public async Task Staff_anchor_found_for_local_submitted_application_commits_redaction_without_disclosure() + { + WorkspaceStaffOnboarding application = CreateApplication( + Guid.NewGuid(), + WorkspaceStaffOnboardingSource.EnrollmentLink, + Guid.NewGuid(), + "Profile A"); + Guid staffMemberId = Guid.NewGuid(); + Guid resolutionEventId = Guid.NewGuid(); + RecordingUnitOfWork unitOfWork = new(); + StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader outcomes = new( + request => new StaffWorkspaceOnboardingIdentityAnchorOutcome( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved, + staffMemberId, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + resolutionEventId)); + using ServiceProvider provider = CreateProvider( + new FakeRepository(application), + new FakeStaffProvisioner(), + new FakeAccessControl(), + new FakeJoinTokenInspector( + WorkspaceStaffOnboardingTests.OrganizationId, + application.SourceId), + anchorOutcomes: outcomes, + unitOfWork: unitOfWork); + + Result result = + await provider.GetRequiredService< + IWorkspaceStaffOnboardingSubmitter>() + .SubmitWithAuthorityOutcomeAsync( + new SubmitWorkspaceStaffOnboardingCommand( + WorkspaceStaffOnboardingSourceKind.EnrollmentLink, + "secret-token", + application.SubjectId, + "Must not be written", + null, + null, + null, + null, + null, + null), + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal( + WorkspaceStaffOnboardingSubmissionOutcomeKind + .AuthorityMovedToStaff, + result.Value.Kind); + Assert.Null(result.Value.Application); + Assert.Equal(1, unitOfWork.SaveCount); + Assert.Equal(WorkspaceStaffOnboardingState.StaffReady, application.Status); + Assert.Equal(staffMemberId, application.StaffMemberId); + Assert.Null(application.DisplayName); + Assert.Null(application.VerifiedAccountEmail); + Assert.Single(application.DomainEvents.OfType< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent>()); + } + + [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() { @@ -301,7 +1062,7 @@ public async Task Submission_rejects_a_member_without_current_auth_admission() [Theory] [InlineData(WorkspaceStaffOnboardingSourceKind.Invitation)] [InlineData(WorkspaceStaffOnboardingSourceKind.EnrollmentLink)] - public async Task Terminal_source_replay_returns_the_existing_application_without_reopening_it( + public async Task Terminal_source_replay_with_local_staff_coordinates_is_not_disclosed( WorkspaceStaffOnboardingSourceKind sourceKind) { Guid sourceId = Guid.NewGuid(); @@ -333,6 +1094,8 @@ public async Task Terminal_source_replay_returns_the_existing_application_withou WorkspaceStaffOnboardingTests.Now.AddMinutes(1)); Assert.True(accepted.IsSuccess, accepted.Error.Code); Assert.True(application.MarkStaffReady( + Guid.NewGuid(), + Guid.NewGuid(), Guid.NewGuid(), WorkspaceStaffOnboardingTests.Now.AddMinutes(2)).IsSuccess); Assert.True(application.Complete( @@ -372,9 +1135,9 @@ public async Task Terminal_source_replay_returns_the_existing_application_withou null), CancellationToken.None); - Assert.True(replayed.IsSuccess, replayed.Error.Code); - Assert.Equal(application.Id, replayed.Value.ApplicationId); - Assert.Equal(WorkspaceStaffOnboardingStatus.Completed, replayed.Value.Status); + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors.IdentityAnchorConflict, + replayed.Error); Assert.Equal(completedVersion, application.Version); Assert.Null(application.DisplayName); Assert.Single(applications.Applications); @@ -618,13 +1381,123 @@ 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 WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + CreateContinuationFact(WorkspaceStaffOnboarding application) + { + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent + continuation = Assert.Single(application.DomainEvents.OfType< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent>()); + return new( + continuation.EventId, + continuation.ScopeId, + continuation.OccurredAtUtc, + continuation.ApplicationId, + continuation.StaffMemberId); + } + + private static WorkspaceStaffOnboarding CreateRolledBackStaffReady( + WorkspaceStaffOnboarding source, + Guid claimId, + Guid staffMemberId) + { + WorkspaceStaffOnboarding restored = CreateApplication( + source.Id, + source.SourceKind, + source.SourceId, + "Ada Operator"); + Assert.True(restored.ObserveClaimAccepted( + claimId, + 1, + WorkspaceStaffOnboardingTests.Now.AddMinutes(1)).IsSuccess); + Assert.True(restored.MarkStaffReady( + staffMemberId, + source.IdentityAnchorExpectedResolutionEventId!.Value, + source.IdentityAnchorContinuationEventId!.Value, + WorkspaceStaffOnboardingTests.Now.AddMinutes(2)).IsSuccess); + restored.ClearDomainEvents(); + return restored; + } + + 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, + FakeRestrictionProjectionRepository? restrictionProjections = null, + FakeOperationLock? operationLock = null, + IStaffPropertyAssignmentProvisioner? staffProperties = null, + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader? anchorOutcomes = + null, + IWorkspaceStaffAccessProcessRepository? accessProcesses = null, + IUnitOfWork? unitOfWork = null) { HostApplicationBuilder builder = new(new HostApplicationBuilderSettings { @@ -652,19 +1525,62 @@ private static ServiceProvider CreateProvider( services.AddSingleton(applications); services.AddSingleton< IWorkspaceStaffOnboardingProcessingRestrictionProjectionRepository>( - new FakeRestrictionProjectionRepository( - applications.Applications)); + restrictionProjections ?? + new FakeRestrictionProjectionRepository( + applications.Applications)); services.AddSingleton( - new FakeOperationLock()); + operationLock ?? new FakeOperationLock()); + services.AddSingleton( + accessProcesses ?? + WorkspaceStaffAccessMutationTestSupport.NoOpenProcesses); + services.AddSingleton( + new FakeStaffAccessOperationLock()); services.AddSingleton(new FakeAccessPlanRepository(plans)); + services.AddSingleton( + deferredWithdrawals ?? + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository()); services.AddSingleton(staff); - services.AddSingleton(new FakeStaffPropertyProvisioner()); + services.AddSingleton( + anchorOutcomes ?? + new StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader( + request => + { + if (staff.LastRequest?.OperationId != request.ApplicationId || + !staff.StaffMemberId.HasValue) + { + return StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + .Absent(request); + } + + return new StaffWorkspaceOnboardingIdentityAnchorOutcome( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Unresolved, + staff.StaffMemberId.Value, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Active, + string.Equals( + staff.LastRequest.AuthSubjectId, + request.ExpectedAuthSubjectId, + StringComparison.Ordinal) + ? StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Exact + : StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Mismatch, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + staff.ResolutionEventId); + })); + services.AddSingleton( + staffProperties ?? new FakeStaffPropertyProvisioner()); services.AddSingleton(access); services.AddSingleton(profiles); services.AddSingleton(profiles); services.AddSingleton(new AllowAllAuthorizationService()); services.AddSingleton(new FakePropertyProjectionRepository()); services.AddSingleton(tokenInspector); + services.AddSingleton( + claims ?? new FakeOrganizationEnrollmentClaimInspector()); services.AddSingleton( admissions ?? new FakeAdmissionReader()); services.AddSingleton( @@ -674,7 +1590,8 @@ private static ServiceProvider CreateProvider( services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(new FakeClock()); services.AddSingleton(new FakeIdGenerator()); - services.AddSingleton(new TestUnitOfWork()); + services.AddSingleton( + unitOfWork ?? new TestUnitOfWork()); services.AddWorkspacesApplication(new ConfigurationBuilder().Build(), "global"); return services.BuildServiceProvider(); } @@ -687,6 +1604,19 @@ public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; } + private sealed class RecordingUnitOfWork : IUnitOfWork + { + public string ModuleName => WorkspacesModuleMetadata.Name; + public int SaveCount { get; private set; } + + public Task SaveChangesAsync( + CancellationToken cancellationToken = default) + { + this.SaveCount++; + return Task.CompletedTask; + } + } + private static WorkspaceStaffAccessPlan CreateActivePlan( WorkspaceStaffOnboardingSource sourceKind, Guid sourceId, @@ -799,6 +1729,13 @@ public Task AddAsync(WorkspaceStaffOnboarding application, CancellationToken can this.applications.Add(application); return Task.CompletedTask; } + + public void Replace(WorkspaceStaffOnboarding application) + { + int index = this.applications.FindIndex(item => item.Id == application.Id); + Assert.True(index >= 0); + this.applications[index] = application; + } } private sealed class FakeAccessPlanRepository(params WorkspaceStaffAccessPlan[] seed) @@ -859,18 +1796,29 @@ public Task AddAsync( this.projections.Add(projection.ApplicationId, projection); return Task.CompletedTask; } + + public WorkspaceStaffOnboardingProcessingRestrictionProjection Get( + Guid applicationId) => this.projections[applicationId]; } - private sealed class FakeOperationLock + private sealed class FakeOperationLock(List? calls = null) : IWorkspaceStaffOnboardingOperationLock { public Task AcquireSourceReadAsync( Guid sourceId, - CancellationToken cancellationToken) => Task.CompletedTask; + CancellationToken cancellationToken) + { + calls?.Add("source-read"); + return Task.CompletedTask; + } public Task AcquireSourceWriteAsync( Guid sourceId, - CancellationToken cancellationToken) => Task.CompletedTask; + CancellationToken cancellationToken) + { + calls?.Add("source-write"); + return Task.CompletedTask; + } public Task AcquireApplicantAsync( Guid sourceId, @@ -879,13 +1827,79 @@ public Task AcquireApplicantAsync( public Task TryAcquireAsync( Guid applicationId, + CancellationToken cancellationToken) + { + calls?.Add("application"); + return Task.FromResult(true); + } + } + + private sealed class FakeStaffAccessOperationLock + : IWorkspaceStaffAccessOperationLock + { + public Task AcquireStaffAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => Task.CompletedTask; + + public Task TryAcquireProcessAsync( + Guid processId, + CancellationToken cancellationToken) => Task.FromResult(true); + } + + private sealed class SingleOpenAccessProcessRepository( + WorkspaceStaffAccessProcess open) + : IWorkspaceStaffAccessProcessRepository + { + public Task GetAsync( + Guid processId, + CancellationToken cancellationToken) => + Task.FromResult( + open.Id == processId ? open : null); + + public Task GetByStaffVersionAsync( + Guid staffMemberId, + long targetStaffVersion, + CancellationToken cancellationToken) => + Task.FromResult( + open.StaffMemberId == staffMemberId && + open.TargetStaffVersion == targetStaffVersion + ? open + : null); + + public Task GetOpenByStaffAsync( + Guid staffMemberId, + CancellationToken cancellationToken) => + Task.FromResult( + open.StaffMemberId == staffMemberId ? open : null); + + public Task + GetLatestCompletedSuspensionAsync( + Guid staffMemberId, + string subjectId, + CancellationToken cancellationToken) => + Task.FromResult(null); + + public Task GetCompletedDepartureAsync( + Guid staffMemberId, + long targetStaffVersion, + CancellationToken cancellationToken) => + Task.FromResult(null); + + public Task ListOpenAsync( + PageRequest page, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task AddAsync( + WorkspaceStaffAccessProcess process, CancellationToken cancellationToken) => - Task.FromResult(true); + throw new NotSupportedException(); } private sealed class FakeStaffProvisioner : IStaffOnboardingProvisioner { public Guid? StaffMemberId { get; init; } = Guid.NewGuid(); + public Guid ResolutionEventId { get; init; } = Guid.NewGuid(); public string? ErrorCode { get; init; } public int CallCount { get; private set; } public StaffOnboardingProvisioningRequest? LastRequest { get; private set; } @@ -897,20 +1911,38 @@ public Task ProvisionAsync( this.CallCount++; this.LastRequest = request; return Task.FromResult(this.ErrorCode is null - ? new StaffOnboardingProvisioningResult(true, this.StaffMemberId, null) + ? new StaffOnboardingProvisioningResult( + true, + this.StaffMemberId, + ErrorCode: null, + ResolutionEventId: this.ResolutionEventId) : new StaffOnboardingProvisioningResult(false, null, this.ErrorCode)); } } - private sealed class FakeStaffPropertyProvisioner : IStaffPropertyAssignmentProvisioner + private sealed class FakeStaffPropertyProvisioner + : IStaffPropertyAssignmentProvisioner { + public string? ErrorCode { get; init; } + public Action? BeforeResult { get; init; } + public int CallCount { get; private set; } + public Task ReconcileAsync( StaffPropertyAssignmentProvisioningRequest request, - CancellationToken cancellationToken = default) => Task.FromResult( - new StaffPropertyAssignmentProvisioningResult( + CancellationToken cancellationToken = default) + { + this.CallCount++; + this.BeforeResult?.Invoke(); + return Task.FromResult(this.ErrorCode is null + ? new StaffPropertyAssignmentProvisioningResult( true, request.PropertyIds.ToArray(), - null)); + null) + : new StaffPropertyAssignmentProvisioningResult( + false, + [], + this.ErrorCode)); + } } private sealed class FakeAccessControl : IAccessControlRoleProvisioner diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingIdentityAnchorConvergenceTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingIdentityAnchorConvergenceTests.cs new file mode 100644 index 00000000..d7329ea3 --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingIdentityAnchorConvergenceTests.cs @@ -0,0 +1,1012 @@ +namespace BunkFy.Modules.Workspaces.Tests; + +using System.Reflection; +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Application.Handlers; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.AccessControl; +using Gma.Framework.Pagination; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; +using Gma.Modules.AccessControl.Contracts; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class WorkspaceStaffOnboardingIdentityAnchorConvergenceTests +{ + [Fact] + public async Task Absent_anchor_preserves_applicant_authority_without_mutation() + { + WorkspaceStaffOnboarding application = CreateApplication(); + long version = application.Version; + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence(Absent(application)); + + var result = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome.Absent, + result.Value.Outcome); + Assert.Equal(version, application.Version); + Assert.NotNull(application.DisplayName); + } + + [Fact] + public async Task Exact_active_anchor_binds_and_redacts_once() + { + WorkspaceStaffOnboarding application = CreateApplication(); + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence(Unresolved( + application, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact)); + + var first = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + long convergedVersion = application.Version; + var replay = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(first.IsSuccess, first.Error.Code); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ConvergedNow, + first.Value.Outcome); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome.Active, + replay.Value.Outcome); + Assert.Equal(StaffMemberId, application.StaffMemberId); + Assert.Equal(WorkspaceStaffOnboardingState.StaffReady, application.Status); + Assert.Equal(convergedVersion, application.Version); + AssertApplicantDataRedacted(application); + } + + [Fact] + public async Task Exact_suspended_anchor_denies_and_terminalizes_without_resurrection() + { + WorkspaceStaffOnboarding application = CreateApplication(); + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle lifecycle = + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Suspended; + RecordingRoles roles = new(); + RecordingScopedProfiles profiles = new(); + StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader reader = new( + request => Unresolved( + application, + lifecycle, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact)); + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence(reader, roles, profiles); + + var first = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + long resolvedVersion = application.Version; + var replay = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + lifecycle = StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active; + var laterActive = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(first.IsSuccess, first.Error.Code); + Assert.True(replay.IsSuccess, replay.Error.Code); + Assert.True(laterActive.IsSuccess, laterActive.Error.Code); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionPending, + first.Value.Outcome); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionPending, + replay.Value.Outcome); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionPending, + laterActive.Value.Outcome); + Assert.Equal(resolvedVersion, application.Version); + Assert.Equal(WorkspaceStaffOnboardingState.Superseded, application.Status); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted, + application.IdentityAnchorResolutionDisposition); + Assert.Equal(1, profiles.DenyCount); + Assert.Equal(2, roles.RemovalCount); + AssertApplicantDataRedacted(application); + } + + [Theory] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Departed, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact)] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Anonymised, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Missing)] + public async Task Terminal_target_denies_access_then_records_resolution_intent( + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle lifecycle, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch subjectMatch) + { + WorkspaceStaffOnboarding application = CreateApplication(); + RecordingRoles roles = new(); + RecordingScopedProfiles profiles = new(); + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence( + Unresolved(application, lifecycle, subjectMatch), + roles, + profiles); + + var result = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionPending, + result.Value.Outcome); + Assert.Equal(WorkspaceStaffOnboardingState.Superseded, application.Status); + Assert.Equal( + ResolutionEventId, + application.IdentityAnchorResolutionEventId); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted, + application.IdentityAnchorResolutionDisposition); + Assert.Equal(1, profiles.DenyCount); + Assert.Equal(2, roles.RemovalCount); + AssertApplicantDataRedacted(application); + } + + [Theory] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Missing)] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Missing, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact)] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Departed, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Missing)] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Anonymised, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact)] + public async Task Mismatched_or_missing_target_fails_closed_without_redaction( + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle lifecycle, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch subjectMatch) + { + WorkspaceStaffOnboarding application = CreateApplication(); + long version = application.Version; + RecordingRoles roles = new(); + RecordingScopedProfiles profiles = new(); + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence( + Unresolved( + application, + lifecycle, + subjectMatch), + roles, + profiles); + + var result = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors.IdentityAnchorConflict, + result.Error); + Assert.Equal(version, application.Version); + Assert.Null(application.StaffMemberId); + Assert.NotNull(application.DisplayName); + Assert.Equal(0, profiles.DenyCount); + Assert.Equal(0, roles.RemovalCount); + } + + [Theory] + [InlineData(StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active)] + [InlineData(StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Suspended)] + [InlineData(StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Departed)] + public async Task Historical_subject_mismatch_denies_old_subject_and_supersedes( + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle lifecycle) + { + WorkspaceStaffOnboarding application = CreateApplication(); + RecordingRoles roles = new(); + RecordingScopedProfiles profiles = new(); + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence( + Unresolved( + application, + lifecycle, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Mismatch), + roles, + profiles); + + var result = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionPending, + result.Value.Outcome); + Assert.Equal(WorkspaceStaffOnboardingState.Superseded, application.Status); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted, + application.IdentityAnchorResolutionDisposition); + Assert.Equal(application.SubjectId, Assert.Single(profiles.DeniedSubjects)); + Assert.Equal(2, roles.RemovalCount); + AssertApplicantDataRedacted(application); + } + + [Fact] + public async Task Historical_completed_subject_mismatch_is_forced_to_superseded() + { + WorkspaceStaffOnboarding application = CreateApplication(); + SetStatusForLegacyMaterialization( + application, + WorkspaceStaffOnboardingState.Completed); + RecordingScopedProfiles profiles = new(); + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence( + Unresolved( + application, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Mismatch), + profiles: profiles); + + var result = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal(WorkspaceStaffOnboardingState.Superseded, application.Status); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted, + application.IdentityAnchorResolutionDisposition); + Assert.Equal(application.SubjectId, Assert.Single(profiles.DeniedSubjects)); + } + + [Theory] + [InlineData( + WorkspaceStaffOnboardingState.Rejected, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted)] + [InlineData( + WorkspaceStaffOnboardingState.Superseded, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted)] + [InlineData( + WorkspaceStaffOnboardingState.Expired, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted)] + [InlineData( + WorkspaceStaffOnboardingState.Withdrawn, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted)] + public async Task Historical_mismatch_preserves_negative_terminal_truth( + WorkspaceStaffOnboardingState state, + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + expectedDisposition) + { + WorkspaceStaffOnboarding application = CreateApplication(); + SetStatusForLegacyMaterialization(application, state); + RecordingScopedProfiles profiles = new(); + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence( + Unresolved( + application, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Mismatch), + profiles: profiles); + + var result = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal(state, application.Status); + Assert.Equal( + expectedDisposition, + application.IdentityAnchorResolutionDisposition); + Assert.Equal(application.SubjectId, Assert.Single(profiles.DeniedSubjects)); + AssertApplicantDataRedacted(application); + } + + [Fact] + public async Task Exact_resolved_outcome_observes_once_without_changing_captured_version() + { + WorkspaceStaffOnboarding application = CreateCompletedApplication(); + long capturedVersion = + application.IdentityAnchorResolutionApplicationVersion!.Value; + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence(Resolved(application, capturedVersion)); + + var first = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + long observedVersion = application.Version; + var replay = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionObserved, + first.Value.Outcome); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionPending, + replay.Value.Outcome); + Assert.Equal(capturedVersion + 1, observedVersion); + Assert.Equal(observedVersion, application.Version); + Assert.Equal( + capturedVersion, + application.IdentityAnchorResolutionApplicationVersion); + } + + [Fact] + public async Task Resolved_outcome_with_another_version_is_a_conflict() + { + WorkspaceStaffOnboarding application = CreateCompletedApplication(); + long version = application.Version; + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence(Resolved(application, version + 1)); + + var result = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(version, application.Version); + Assert.Null(application.IdentityAnchorResolutionObservedAtUtc); + } + + [Theory] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + 0, + 0)] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Suspended, + 1, + 2)] + public async Task Completed_live_target_records_resolution_without_removing_access( + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle lifecycle, + int expectedProfileDenials, + int expectedRoleRemovals) + { + WorkspaceStaffOnboarding application = CreateApplication(); + Assert.True(application.ObserveClaimAccepted( + Guid.NewGuid(), + 1, + Now.AddMinutes(1)).IsSuccess); + Assert.True(application.MarkStaffReady( + StaffMemberId, + ResolutionEventId, + ContinuationEventId, + Now.AddMinutes(2)).IsSuccess); + application.ClearDomainEvents(); + SetStatusForLegacyMaterialization( + application, + WorkspaceStaffOnboardingState.Completed); + RecordingRoles roles = new(); + RecordingScopedProfiles profiles = new(); + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence( + Unresolved( + application, + lifecycle, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact), + roles, + profiles); + + var result = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionPending, + result.Value.Outcome); + Assert.Equal(expectedProfileDenials, profiles.DenyCount); + Assert.Equal(expectedRoleRemovals, roles.RemovalCount); + Assert.Equal(WorkspaceStaffOnboardingState.Completed, application.Status); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + application.IdentityAnchorResolutionDisposition); + } + + [Fact] + public async Task Expected_event_target_must_match_the_authoritative_anchor() + { + WorkspaceStaffOnboarding application = CreateApplication(); + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence(Unresolved( + application, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact)); + long version = application.Version; + + var result = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None, + Guid.NewGuid()); + + Assert.True(result.IsFailure); + Assert.Equal(version, application.Version); + Assert.Null(application.StaffMemberId); + Assert.NotNull(application.DisplayName); + } + + [Fact] + public async Task Expected_event_target_rejects_an_absent_authoritative_anchor() + { + WorkspaceStaffOnboarding application = CreateApplication(); + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence(Absent(application)); + long version = application.Version; + + var result = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None, + StaffMemberId); + + Assert.True(result.IsFailure); + Assert.Equal(version, application.Version); + Assert.Null(application.StaffMemberId); + Assert.NotNull(application.DisplayName); + } + + [Fact] + public async Task Open_suspension_process_blocks_without_precommit_resolution() + { + WorkspaceStaffOnboarding application = CreateStaffReadyApplication(); + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle lifecycle = + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active; + RecordingRoles roles = new(); + RecordingScopedProfiles profiles = new(); + List calls = []; + SingleOpenProcessRepository accessProcesses = new( + CreateAccessProcess( + application, + WorkspaceStaffAccessTargetState.Suspended), + calls); + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence( + new StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader( + request => Unresolved( + application, + lifecycle, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Exact)), + roles, + profiles, + accessProcesses, + calls); + + var candidate = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + Assert.True(candidate.IsSuccess, candidate.Error.Code); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome.Active, + candidate.Value.Outcome); + Assert.Empty(calls); + + var result = await convergence.FenceActiveGrantAcquiredAsync( + application, + StaffMemberId, + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorLifecycleTransitionPending, + result.Error); + Assert.Equal(["staff-coordinate", "open-process"], calls); + Assert.Equal(WorkspaceStaffOnboardingState.StaffReady, application.Status); + Assert.Null(application.IdentityAnchorResolutionEventId); + Assert.Equal(0, profiles.DenyCount); + Assert.Equal(0, roles.RemovalCount); + + accessProcesses.OpenProcess = null; + calls.Clear(); + var cleared = await convergence.FenceActiveGrantAcquiredAsync( + application, + StaffMemberId, + CancellationToken.None); + + Assert.True(cleared.IsSuccess, cleared.Error.Code); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome.Active, + cleared.Value.Outcome); + Assert.Null(application.IdentityAnchorResolutionEventId); + + lifecycle = + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Suspended; + var committedSuspension = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(committedSuspension.IsSuccess, committedSuspension.Error.Code); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionPending, + committedSuspension.Value.Outcome); + Assert.Equal(WorkspaceStaffOnboardingState.Superseded, application.Status); + Assert.Equal(1, profiles.DenyCount); + Assert.Equal(2, roles.RemovalCount); + } + + [Theory] + [InlineData(WorkspaceStaffAccessTargetState.Active)] + [InlineData(WorkspaceStaffAccessTargetState.Suspended)] + [InlineData(WorkspaceStaffAccessTargetState.Departed)] + public async Task Open_lifecycle_process_blocks_suspended_outcome_until_authority_is_reread( + WorkspaceStaffAccessTargetState openTargetState) + { + WorkspaceStaffOnboarding application = CreateStaffReadyApplication(); + long version = application.Version; + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle lifecycle = + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Suspended; + RecordingRoles roles = new(); + RecordingScopedProfiles profiles = new(); + SingleOpenProcessRepository accessProcesses = new( + CreateAccessProcess( + application, + openTargetState)); + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence( + new StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader( + request => Unresolved( + application, + lifecycle, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Exact)), + roles, + profiles, + accessProcesses); + + var result = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorLifecycleTransitionPending, + result.Error); + Assert.Equal(version, application.Version); + Assert.Equal(WorkspaceStaffOnboardingState.StaffReady, application.Status); + Assert.Null(application.IdentityAnchorResolutionEventId); + Assert.Equal(0, profiles.DenyCount); + Assert.Equal(0, roles.RemovalCount); + + accessProcesses.OpenProcess = null; + lifecycle = StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active; + var abortedResume = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(abortedResume.IsSuccess, abortedResume.Error.Code); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome.Active, + abortedResume.Value.Outcome); + Assert.Equal(version, application.Version); + Assert.Null(application.IdentityAnchorResolutionEventId); + + lifecycle = + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Suspended; + var committedSuspension = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(committedSuspension.IsSuccess, committedSuspension.Error.Code); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorConvergenceOutcome + .ResolutionPending, + committedSuspension.Value.Outcome); + Assert.Equal(WorkspaceStaffOnboardingState.Superseded, application.Status); + Assert.Equal(1, profiles.DenyCount); + Assert.Equal(2, roles.RemovalCount); + } + + [Fact] + public async Task Historical_mismatch_waits_for_open_current_subject_process() + { + WorkspaceStaffOnboarding application = CreateApplication(); + long version = application.Version; + RecordingRoles roles = new(); + RecordingScopedProfiles profiles = new(); + SingleOpenProcessRepository accessProcesses = new( + CreateAccessProcess( + application, + WorkspaceStaffAccessTargetState.Active, + "subject:current-owner")); + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence( + new StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader( + request => Unresolved( + application, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Mismatch)), + roles, + profiles, + accessProcesses); + + var result = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors + .IdentityAnchorLifecycleTransitionPending, + result.Error); + Assert.Equal(version, application.Version); + Assert.Null(application.StaffMemberId); + Assert.NotNull(application.DisplayName); + Assert.Equal(0, profiles.DenyCount); + Assert.Equal(0, roles.RemovalCount); + } + + [Fact] + public async Task Rejected_exact_anchor_denies_access_and_records_rejected_disposition() + { + WorkspaceStaffOnboarding application = CreateApplication(); + Guid claimId = Guid.NewGuid(); + Assert.True(application.ObserveClaimRequested( + claimId, + 1, + Now.AddMinutes(1)).IsSuccess); + Assert.True(application.ObserveClaimRejected( + claimId, + 2, + Now.AddMinutes(2)).IsSuccess); + RecordingRoles roles = new(); + RecordingScopedProfiles profiles = new(); + WorkspaceStaffOnboardingIdentityAnchorConvergence convergence = + CreateConvergence( + Unresolved( + application, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact), + roles, + profiles); + + var result = await convergence.ConvergeAcquiredAsync( + application, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal(1, profiles.DenyCount); + Assert.Equal(2, roles.RemovalCount); + Assert.Equal( + WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted, + application.IdentityAnchorResolutionDisposition); + } + + private static WorkspaceStaffOnboardingIdentityAnchorConvergence + CreateConvergence( + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome, + RecordingRoles? roles = null, + RecordingScopedProfiles? profiles = null) + => CreateConvergence( + new StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader( + request => request.ApplicationId == outcome.ApplicationId + ? outcome + : StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + .Absent(request)), + roles, + profiles); + + private static WorkspaceStaffOnboardingIdentityAnchorConvergence + CreateConvergence( + StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader reader, + RecordingRoles? roles = null, + RecordingScopedProfiles? profiles = null, + IWorkspaceStaffAccessProcessRepository? accessProcesses = null, + List? accessLockCalls = null) + { + roles ??= new RecordingRoles(); + profiles ??= new RecordingScopedProfiles(); + accessProcesses ??= + WorkspaceStaffAccessMutationTestSupport.NoOpenProcesses; + return new WorkspaceStaffOnboardingIdentityAnchorConvergence( + reader, + WorkspaceStaffAccessMutationTestSupport.Create( + accessProcesses, + calls: accessLockCalls), + accessProcesses, + new WorkspaceAccessProvisioner( + roles, + profiles: null!, + profiles), + new TestClock(), + new FixedIdGenerator(ContinuationEventId)); + } + + private static StaffWorkspaceOnboardingIdentityAnchorOutcome Absent( + WorkspaceStaffOnboarding application) => + StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader.Absent( + new StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest( + application.Id, + application.SubjectId)); + + private static StaffWorkspaceOnboardingIdentityAnchorOutcome Unresolved( + WorkspaceStaffOnboarding application, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle lifecycle, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch subjectMatch) => new( + application.Id, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved, + StaffMemberId, + lifecycle, + subjectMatch, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + ResolutionEventId); + + private static StaffWorkspaceOnboardingIdentityAnchorOutcome Resolved( + WorkspaceStaffOnboarding application, + long version) => new( + application.Id, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved, + StaffMemberId, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact, + version, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + ResolutionEventId); + + private static WorkspaceStaffOnboarding CreateApplication() => + WorkspaceStaffOnboardingTests.CreateApplication(); + + private static WorkspaceStaffOnboarding CreateCompletedApplication() + { + WorkspaceStaffOnboarding application = CreateApplication(); + Assert.True(application.ObserveClaimAccepted( + Guid.NewGuid(), + 1, + Now.AddMinutes(1)).IsSuccess); + Assert.True(application.MarkStaffReady( + StaffMemberId, + ResolutionEventId, + ContinuationEventId, + Now.AddMinutes(2)).IsSuccess); + Assert.True(application.Complete(Now.AddMinutes(3)).IsSuccess); + return application; + } + + private static WorkspaceStaffOnboarding CreateStaffReadyApplication() + { + WorkspaceStaffOnboarding application = CreateApplication(); + Assert.True(application.ObserveClaimAccepted( + Guid.NewGuid(), + 1, + Now.AddMinutes(1)).IsSuccess); + Assert.True(application.MarkStaffReady( + StaffMemberId, + ResolutionEventId, + ContinuationEventId, + Now.AddMinutes(2)).IsSuccess); + application.ClearDomainEvents(); + return application; + } + + private static WorkspaceStaffAccessProcess CreateAccessProcess( + WorkspaceStaffOnboarding application, + WorkspaceStaffAccessTargetState targetState, + string? subjectId = null) => + WorkspaceStaffAccessProcess.Create( + Guid.NewGuid(), + application.ScopeId, + StaffMemberId, + subjectId ?? application.SubjectId, + targetState, + 2, + DateOnly.FromDateTime(Now.UtcDateTime), + "integration:staff", + [], + Now).Value; + + private static void AssertApplicantDataRedacted( + WorkspaceStaffOnboarding application) + { + Assert.Null(application.VerifiedAccountEmail); + Assert.Null(application.DisplayName); + Assert.Null(application.LegalName); + Assert.Null(application.WorkEmail); + Assert.Null(application.WorkPhone); + Assert.Null(application.EmployeeNumber); + Assert.Null(application.JobTitle); + Assert.Null(application.Department); + } + + private static void SetStatusForLegacyMaterialization( + WorkspaceStaffOnboarding application, + WorkspaceStaffOnboardingState status) => + typeof(WorkspaceStaffOnboarding) + .GetProperty( + nameof(WorkspaceStaffOnboarding.Status), + BindingFlags.Instance | + BindingFlags.Public | + BindingFlags.NonPublic)! + .SetValue(application, status); + + private sealed class RecordingRoles : IAccessControlRoleProvisioner + { + public int RemovalCount { get; private set; } + + public Task EnsureRoleAsync( + AccessControlRoleDefinition role, + CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task EnsureAssignmentAsync( + AccessSubject subject, + string roleName, + AccessScope scope, + CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task RemoveAssignmentAsync( + AccessSubject subject, + string roleName, + AccessScope scope, + CancellationToken cancellationToken = default) + { + this.RemovalCount++; + return Task.FromResult( + AccessControlAssignmentRemovalOutcome.NotFound); + } + + public Task HasAssignmentAsync( + AccessSubject subject, + string roleName, + AccessScope scope, + CancellationToken cancellationToken = default) => + Task.FromResult(false); + + public Task> + ListAssignmentsAsync( + string roleName, + AccessScope scope, + int page, + int pageSize, + CancellationToken cancellationToken = default) => + Task.FromResult(new AccessControlPage( + [], + page, + pageSize, + HasMore: false)); + } + + private sealed class RecordingScopedProfiles + : IScopedAccessProfileProvisioner + { + public int DenyCount { get; private set; } + public List DeniedSubjects { get; } = []; + + public Task + GetSubjectScopedAssignmentsAsync( + AccessSubject subject, + AccessScope ownerScope, + CancellationToken cancellationToken = default) => + Task.FromResult(new ScopedAccessProfileAssignmentSet( + subject, + ownerScope, + [])); + + public Task + ReconcileSubjectScopedAssignmentsAsync( + AccessSubject subject, + AccessScope ownerScope, + IReadOnlyCollection targets, + AccessSubject actor, + CancellationToken cancellationToken = default) + { + Assert.Empty(targets); + this.DenyCount++; + this.DeniedSubjects.Add(subject.Id); + return Task.FromResult( + new ScopedAccessProfileAssignmentReconciliation( + subject, + ownerScope, + [], + AssignedCount: 0, + UnassignedCount: 0)); + } + } + + private sealed class SingleOpenProcessRepository( + WorkspaceStaffAccessProcess? process, + List? calls = null) + : IWorkspaceStaffAccessProcessRepository + { + public WorkspaceStaffAccessProcess? OpenProcess { get; set; } = process; + + public Task GetAsync( + Guid processId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetByStaffVersionAsync( + Guid staffMemberId, + long targetStaffVersion, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetOpenByStaffAsync( + Guid staffMemberId, + CancellationToken cancellationToken) + { + calls?.Add("open-process"); + return Task.FromResult(this.OpenProcess); + } + + public Task + GetLatestCompletedSuspensionAsync( + Guid staffMemberId, + string subjectId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetCompletedDepartureAsync( + Guid staffMemberId, + long targetStaffVersion, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task< + BunkFy.Modules.Workspaces.Contracts + .WorkspaceStaffAccessProcessListResponse> ListOpenAsync( + PageRequest page, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task AddAsync( + WorkspaceStaffAccessProcess candidate, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + + private sealed class TestClock : ISystemClock + { + public DateTimeOffset UtcNow => Now.AddMinutes(10); + } + + private sealed class FixedIdGenerator(Guid value) : IIdGenerator + { + public Guid NewId() => value; + } + + private static readonly Guid StaffMemberId = + Guid.Parse("30000000-0000-0000-0000-000000000003"); + private static readonly Guid ResolutionEventId = + Guid.Parse("30000000-0000-0000-0000-000000000004"); + private static readonly Guid ContinuationEventId = + Guid.Parse("30000000-0000-0000-0000-000000000005"); + private static readonly DateTimeOffset Now = + WorkspaceStaffOnboardingTests.Now; +} 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..dd38a02c 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 @@ -1,7 +1,13 @@ namespace BunkFy.Modules.Workspaces.Tests; +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application; using BunkFy.Modules.Workspaces.Application.Handlers; using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; +using Gma.Modules.Organizations.Contracts; internal static class WorkspaceStaffOnboardingMutationTestSupport { @@ -13,6 +19,23 @@ public static WorkspaceStaffOnboardingMutationCoordinator Create( public static WorkspaceStaffOnboardingMutationCoordinator CreateForSourceOnly() => new(new NoOpOperationLock(), null!); + public static WorkspaceStaffOnboardingIdentityAnchorConvergence + CreateIdentityAnchorConvergence( + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader? outcomes = + null) => + new( + outcomes ?? + new StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader(), + WorkspaceStaffAccessMutationTestSupport.Create( + WorkspaceStaffAccessMutationTestSupport.NoOpenProcesses), + WorkspaceStaffAccessMutationTestSupport.NoOpenProcesses, + new WorkspaceAccessProvisioner( + roles: null!, + profiles: null!, + scopedProfiles: null!), + new TestClock(), + new TestIds()); + private sealed class NoOpOperationLock : IWorkspaceStaffOnboardingOperationLock { @@ -33,4 +56,80 @@ public Task TryAcquireAsync( Guid applicationId, CancellationToken cancellationToken) => Task.FromResult(true); } + + private sealed class TestClock : ISystemClock + { + public DateTimeOffset UtcNow => + new(2026, 8, 11, 12, 0, 0, TimeSpan.Zero); + } + + private sealed class TestIds : IIdGenerator + { + public Guid NewId() => Guid.NewGuid(); + } +} + +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/WorkspaceStaffOnboardingProcessingRestrictionCommandHandlerTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingProcessingRestrictionCommandHandlerTests.cs index 5da01d9a..1963e305 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingProcessingRestrictionCommandHandlerTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffOnboardingProcessingRestrictionCommandHandlerTests.cs @@ -133,6 +133,8 @@ public async Task Apply_rejects_after_staff_authority_handoff() Assert.True(application.ObserveInvitationAccepted( Now.AddMinutes(-5)).IsSuccess); Assert.True(application.MarkStaffReady( + Guid.NewGuid(), + Guid.NewGuid(), Guid.NewGuid(), Now.AddMinutes(-4)).IsSuccess); WorkspaceStaffOnboardingProcessingRestrictionProjection projection = 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..90e7e5bb 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); @@ -321,10 +327,13 @@ public async Task Resubmission_of_restricted_onboarding_never_contacts_auth() WorkspaceStaffOnboardingMutationTestSupport.Create( applications, operationLock), + WorkspaceStaffOnboardingMutationTestSupport + .CreateIdentityAnchorConvergence(), new RecordingPlanRepository(plan), new WorkspaceStaffJoinTokenAuthorityResolver( new EnrollmentTokenInspector(OrganizationId, sourceId)), admissions, + new FakeOrganizationEnrollmentClaimInspector(), Options.Create(new WorkspaceStaffOnboardingOptions { GlobalAuthScopeId = "global" @@ -375,6 +384,17 @@ private static WorkspaceStaffOnboardingProcessor CreateProcessor( WorkspaceStaffOnboardingMutationTestSupport.Create( applications, operationLock), + new WorkspaceStaffOnboardingIdentityAnchorConvergence( + new StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader(), + WorkspaceStaffAccessMutationTestSupport.Create( + WorkspaceStaffAccessMutationTestSupport.NoOpenProcesses), + WorkspaceStaffAccessMutationTestSupport.NoOpenProcesses, + new WorkspaceAccessProvisioner( + roles: null!, + profiles: null!, + scopedProfiles: null!), + new TestClock(), + new TestIds()), plans, new WorkspaceStaffAccessPlanPolicy( profiles: null!, @@ -388,6 +408,7 @@ private static WorkspaceStaffOnboardingProcessor CreateProcessor( operationalAdmission ?? WorkspaceOperationalAdmissionTestSupport.Allowed(TenantId), new TestClock(), + new TestIds(), NullLogger.Instance); private static 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..ccc9778a 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 @@ -13,6 +13,7 @@ namespace BunkFy.Modules.Workspaces.Tests; using Gma.Framework.Cqrs; using Gma.Framework.Pagination; using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; using Gma.Framework.Runtime.Time; using Gma.Modules.AccessControl.Contracts; using Gma.Modules.Organizations.Contracts; @@ -243,6 +244,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() { @@ -266,16 +322,26 @@ public async Task Accepted_claim_enters_existing_recoverable_processing_path() WorkspaceStaffOnboardingMutationTestSupport.Create( applications, new FakeOperationLock()), + new WorkspaceStaffOnboardingIdentityAnchorConvergence( + new StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader(), + WorkspaceStaffAccessMutationTestSupport.Create( + WorkspaceStaffAccessMutationTestSupport.NoOpenProcesses), + WorkspaceStaffAccessMutationTestSupport.NoOpenProcesses, + null!, + new FakeClock(), + new TestIds()), plans, planPolicy, null!, WorkspaceOperationalAdmissionTestSupport.Allowed( application.ScopeId), new FakeClock(), + new TestIds(), NullLogger.Instance); ReconcileWorkspaceStaffOnboardingRetentionCandidateCommandHandler handler = new( applications, plans, + new FakeWorkspaceStaffDeferredClaimWithdrawalRepository(), WorkspaceStaffOnboardingMutationTestSupport.Create( applications, new FakeOperationLock()), @@ -333,10 +399,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()), @@ -392,6 +460,11 @@ private sealed class FakeClock : ISystemClock public DateTimeOffset UtcNow => Now; } + private sealed class TestIds : IIdGenerator + { + public Guid NewId() => Guid.CreateVersion7(); + } + private sealed class FakeClaimInspector(OrganizationEnrollmentClaimDto? claim) : IOrganizationEnrollmentClaimInspector { @@ -722,6 +795,11 @@ private sealed class FakeClock : ISystemClock public DateTimeOffset UtcNow => Now; } + private sealed class TestIds : IIdGenerator + { + public Guid NewId() => Guid.CreateVersion7(); + } + private sealed class FakeCandidateRepository( IReadOnlyList candidates) : IWorkspaceStaffOnboardingRetentionRepository diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffRetentionCorrelationHandlerTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffRetentionCorrelationHandlerTests.cs index df9f7798..6c020190 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffRetentionCorrelationHandlerTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Application/WorkspaceStaffRetentionCorrelationHandlerTests.cs @@ -45,6 +45,9 @@ public async Task Valid_request_is_normalized_before_persistence() ScrubWorkspaceStaffRetentionCorrelationCommandHandler handler = new( repository, + new + RecordingWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence( + calls: calls), crossGraphLock, WorkspaceStaffAccessMutationTestSupport.Create( calls: calls), @@ -69,6 +72,7 @@ await handler.HandleAsync( "tenant-exclusive", "staff-coordinate", "access-closure", + "identity-anchor-fence", "repository" ], calls); @@ -79,6 +83,51 @@ await handler.HandleAsync( Assert.Equal(0, repository.Request.CompletedAtUtc.Ticks % 10); } + [Fact] + public async Task Unresolved_identity_anchor_blocks_before_scrub() + { + List calls = []; + FakeRepository repository = new(calls); + RecordingWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence + fence = new(allowed: false, calls: calls); + ScrubWorkspaceStaffRetentionCorrelationCommandHandler handler = new( + repository, + fence, + new RecordingWorkspaceCrossGraphMutationLock(calls), + WorkspaceStaffAccessMutationTestSupport.Create(calls: calls), + new RecordingAccessClosure( + calls, + WorkspaceStaffAccessClosureResult.Complete("subject-a")), + new TestClock(DateTimeOffset.UtcNow), + new TestIdGenerator(ReceiptId), + new TestScopeContext(TenantId)); + + Result result = + await handler.HandleAsync( + new( + ExecutionId, + TenantId, + StaffMemberId, + SelectedStaffVersion: 7), + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffRetentionErrors.IdentityAnchorUnavailable, + result.Error); + Assert.Equal(1, fence.CallCount); + Assert.Equal(TenantId, fence.TenantId); + Assert.Equal("subject-a", fence.SubjectId); + Assert.Equal( + [ + "tenant-exclusive", + "staff-coordinate", + "access-closure", + "identity-anchor-fence" + ], + calls); + Assert.Null(repository.Request); + } + [Fact] public async Task Cross_scope_request_fails_before_persistence() { @@ -88,6 +137,8 @@ public async Task Cross_scope_request_fails_before_persistence() ScrubWorkspaceStaffRetentionCorrelationCommandHandler handler = new( repository, + new + RecordingWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence(), crossGraphLock, WorkspaceStaffAccessMutationTestSupport.Create(), new RecordingAccessClosure( @@ -125,6 +176,8 @@ public async Task Malformed_request_fails_before_persistence() ScrubWorkspaceStaffRetentionCorrelationCommandHandler handler = new( repository, + new + RecordingWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence(), crossGraphLock, WorkspaceStaffAccessMutationTestSupport.Create(), new RecordingAccessClosure( 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..5732a69f 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_v15_with_personal_data_v15_has_one_manifest_identity() + { + Assert.Equal(15, WorkspacesTenantTerminationMetadata.CatalogVersion); + Assert.Equal( + 15, + WorkspacesTenantTerminationMetadata.PersonalDataCatalogVersion); + Assert.Equal( + "fad13bd461804b84b0021d633053517f8b52f338d0858fc0d5f99b39046a7bd4", + 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/BunkFy.Modules.Workspaces.Tests.csproj b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/BunkFy.Modules.Workspaces.Tests.csproj index c8a34b71..647471f5 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/BunkFy.Modules.Workspaces.Tests.csproj +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/BunkFy.Modules.Workspaces.Tests.csproj @@ -12,6 +12,7 @@ + @@ -29,6 +30,7 @@ + property.Name) + .ToArray(); + + Assert.Contains( + nameof(WorkspaceStaffOnboardingActionableSummaryDto.HasStaffTarget), + members); + foreach (string forbidden in new[] + { + "SubjectId", + "VerifiedAccountEmail", + "DisplayName", + "LegalName", + "WorkEmail", + "WorkPhone", + "EmployeeNumber", + "JobTitle", + "Department", + "StaffMemberId" + }) + { + Assert.DoesNotContain(forbidden, members); + } + } + [Theory] [InlineData(typeof(WorkspaceStaffOnboardingListResponse))] [InlineData(typeof(WorkspaceStaffAccessProcessListResponse))] 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..bff9afdd 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 @@ -8,6 +8,7 @@ namespace BunkFy.Modules.Workspaces.Tests; using BunkFy.Modules.Workspaces.Api.Requests; using BunkFy.Modules.Workspaces.Application; using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Models; using BunkFy.Modules.Workspaces.Application.Ports; using BunkFy.Modules.Workspaces.Application.Queries; using BunkFy.Modules.Workspaces.Contracts; @@ -34,7 +35,10 @@ public sealed class WorkspacesPersonalDataCatalogTests { [typeof(WorkspaceStaffOnboardingListResponse)] = PaginationMembers(), [typeof(WorkspaceStaffAccessProcessListResponse)] = PaginationMembers(), - [typeof(WorkspaceStaffJoinSourceListResponse)] = PaginationMembers() + [typeof(WorkspaceStaffJoinSourceListResponse)] = PaginationMembers(), + [typeof(WorkspaceStaffIdentityAnchorSourcePage)] = + new([nameof(WorkspaceStaffIdentityAnchorSourcePage.HasMore)], + StringComparer.Ordinal) }; [Fact] @@ -48,7 +52,7 @@ public void Every_catalogue_binding_resolves_to_a_real_member() Type? type = assembly.GetType(binding.Type, throwOnError: false, ignoreCase: false); Assert.NotNull(type); Assert.True( - type.GetProperty(binding.Member, BindingFlags.Instance | BindingFlags.Public) is not null, + BindingResolves(type, binding.Member), $"Unknown member '{binding.Member}' on '{binding.Type}' in assembly '{binding.Assembly}'."); } } @@ -76,6 +80,142 @@ public void Every_selected_command_query_api_admin_response_and_consumed_event_m { AssertType(type, surface); } + + foreach ((PersonalDataSurface surface, Type type, string method) in + BoundaryMethods()) + { + AssertMethod(type, method, surface); + } + } + + [Fact] + public void Identity_anchor_cutover_evidence_is_pseudonymous_and_transient() + { + Assert.Equal(15, Catalogue.CatalogVersion); + Assert.Equal( + WorkspacesTenantTerminationMetadata.PersonalDataCatalogVersion, + Catalogue.CatalogVersion); + foreach (string fieldId in new[] + { + "workspaces.identity-anchor-cutover.historical-evidence-digest", + "workspaces.identity-anchor-cutover.owner-manifest-digest", + "workspaces.identity-anchor-cutover.source-evidence-digest", + "workspaces.identity-anchor-cutover.state-digest" + }) + { + PersonalDataFieldDefinition field = Assert.Single( + Catalogue.Fields, + candidate => candidate.Id == fieldId); + Assert.Equal( + PersonalDataClassification.PseudonymousIdentifier, + field.Classification); + Assert.Equal(PersonalDataSensitivity.Elevated, field.Sensitivity); + Assert.Equal("transient-response", field.RetentionPolicy); + Assert.Equal( + "identity-anchor-cutover-evidence-control", + field.RightsPolicy); + } + + AssertBinding( + typeof(WorkspaceStaffIdentityAnchorCutoverStatus), + nameof(WorkspaceStaffIdentityAnchorCutoverStatus + .SourceEvidenceSha256), + PersonalDataSurface.AdminOutput); + AssertBinding( + typeof(WorkspaceStaffIdentityAnchorCutoverStatus), + nameof(WorkspaceStaffIdentityAnchorCutoverStatus + .AnchorStateSha256), + PersonalDataSurface.AdminOutput); + AssertBinding( + typeof(WorkspaceStaffIdentityAnchorCutoverStatus), + nameof(WorkspaceStaffIdentityAnchorCutoverStatus + .OwnerManifestSha256), + PersonalDataSurface.AdminOutput); + AssertBinding( + typeof(WorkspaceStaffIdentityAnchorCutoverStatus), + nameof(WorkspaceStaffIdentityAnchorCutoverStatus + .HistoricalEvidenceSha256), + PersonalDataSurface.AdminOutput); + AssertBinding( + typeof(WorkspaceStaffIdentityAnchorHistoricalEvidence), + nameof(WorkspaceStaffIdentityAnchorHistoricalEvidence + .EvidenceSha256), + PersonalDataSurface.ApplicationCommand); + AssertBinding( + typeof(WorkspaceStaffIdentityAnchorHistoricalEvidence), + nameof(WorkspaceStaffIdentityAnchorHistoricalEvidence + .EvidenceSha256), + PersonalDataSurface.ApplicationQuery); + AssertBinding( + typeof(ReconcileWorkspaceStaffIdentityAnchorsCommand), + nameof(ReconcileWorkspaceStaffIdentityAnchorsCommand + .ExpectedSourceEvidenceSha256), + PersonalDataSurface.ApplicationCommand); + AssertBinding( + typeof(ReconcileWorkspaceStaffIdentityAnchorsCommand), + nameof(ReconcileWorkspaceStaffIdentityAnchorsCommand + .ExpectedAnchorStateSha256), + PersonalDataSurface.ApplicationCommand); + AssertBinding( + typeof(ReconcileWorkspaceStaffIdentityAnchorsCommand), + nameof(ReconcileWorkspaceStaffIdentityAnchorsCommand + .ExpectedOwnerManifestSha256), + PersonalDataSurface.ApplicationCommand); + } + + [Fact] + public void Historical_no_provision_receipt_has_a_tenant_audit_proof_policy_and_actor_attribution() + { + PersonalDataFieldDefinition[] proofFields = Catalogue.Fields + .Where(field => field.Id.StartsWith( + "workspaces.identity-anchor-historical-no-provision.", + StringComparison.Ordinal)) + .ToArray(); + Assert.Equal(21, proofFields.Length); + Assert.All(proofFields, field => + { + Assert.Equal( + "workspace-historical-no-provision-proof", + field.RetentionPolicy); + Assert.Equal( + "workspace-historical-no-provision-proof-control", + field.RightsPolicy); + Assert.Equal( + "workspaces-identity-anchor-historical-no-provision", + field.AccessPolicy); + Assert.DoesNotContain( + field.Classification, + new[] + { + PersonalDataClassification.DirectIdentifier, + PersonalDataClassification.Contact, + PersonalDataClassification.FreeText + }); + }); + + PersonalDataFieldDefinition reviewer = Assert.Single( + Catalogue.Fields, + field => field.Id == "workspaces.actor-subject-id"); + Assert.Equal( + PersonalDataClassification.AuditAttribution, + reviewer.Classification); + Assert.Equal("auth", reviewer.AuthoritativeOwner); + Assert.Equal("audit-attribution", reviewer.RightsPolicy); + AssertBinding( + typeof(WorkspaceStaffHistoricalNoProvisionReceipt), + nameof(WorkspaceStaffHistoricalNoProvisionReceipt.ReviewerId), + PersonalDataSurface.Persistence); + AssertBinding( + typeof(ReviewWorkspaceStaffHistoricalNoProvisionCommand), + nameof(ReviewWorkspaceStaffHistoricalNoProvisionCommand + .ExpectedOrganizationsSourceStatus), + PersonalDataSurface.ApplicationCommand); + AssertBinding( + typeof( + WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport), + nameof(WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport + .OrganizationsSourceStatus), + PersonalDataSurface.DataRightsExport); } [Fact] @@ -149,12 +289,58 @@ private static void AssertBinding(Type type, string member, PersonalDataSurface Assert.True(found, $"Missing {surface} classification for {type.FullName}.{member}."); } + private static void AssertMethod( + Type type, + string methodName, + PersonalDataSurface surface) + { + MethodInfo method = Assert.Single( + type.GetMethods(BindingFlags.Instance | BindingFlags.Public), + candidate => candidate.Name == methodName); + foreach (ParameterInfo parameter in method.GetParameters() + .Where(parameter => + parameter.ParameterType != typeof(CancellationToken))) + { + AssertBinding( + type, + $"{method.Name}.{parameter.Name}", + surface); + } + } + + private static bool BindingResolves(Type type, string member) + { + if (type.GetProperty( + member, + BindingFlags.Instance | BindingFlags.Public) is not null) + { + return true; + } + + int separator = member.IndexOf('.', StringComparison.Ordinal); + if (separator <= 0 || separator == member.Length - 1) + { + return false; + } + + string methodName = member[..separator]; + string parameterName = member[(separator + 1)..]; + return type.GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Where(method => method.Name == methodName) + .SelectMany(method => method.GetParameters()) + .Any(parameter => string.Equals( + parameter.Name, + parameterName, + StringComparison.Ordinal)); + } + private static IEnumerable Bindings() => Catalogue.Fields.SelectMany(field => field.Bindings); private static Type[] PersistenceTypes() => [ typeof(WorkspaceStaffOnboarding), + typeof(WorkspaceStaffDeferredClaimWithdrawal), typeof(WorkspaceStaffOnboardingCorrectionReceipt), typeof(WorkspaceStaffOnboardingProcessingRestriction), typeof(WorkspaceStaffOnboardingProcessingRestrictionProjection), @@ -167,6 +353,8 @@ private static Type[] PersistenceTypes() => typeof(WorkspaceStaffAccessPlan), typeof(WorkspaceStaffAccessPlanProperty), typeof(WorkspaceStaffRetentionCorrelationReceipt), + typeof(WorkspaceStaffIdentityAnchorSweepCheckpoint), + typeof(WorkspaceStaffHistoricalNoProvisionReceipt), typeof(WorkspaceTerminationFence), typeof(WorkspaceTerminationFenceReceipt), typeof(WorkspaceTenantDestroyOperation), @@ -222,6 +410,17 @@ private static Type[] PersistenceTypes() => typeof( WorkspaceStaffCorrelationAnonymisationRestoreRequest) , + typeof( + PrepareWorkspaceStaffIdentityAnchorSweepPageCommand), + typeof( + AdvanceWorkspaceStaffIdentityAnchorSweepCommand), + typeof( + ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommand), + typeof(WorkspaceStaffIdentityAnchorSweepAdvance), + typeof(WorkspaceStaffIdentityAnchorSweepPageCounts), + typeof(ReconcileWorkspaceStaffIdentityAnchorsPayload), + typeof( + ReviewWorkspaceStaffHistoricalNoProvisionCommand), typeof(ApplyWorkspaceTerminationFenceCommand), typeof(ReleaseWorkspaceTerminationFenceCommand) }) @@ -230,6 +429,24 @@ private static Type[] PersistenceTypes() => } yield return (PersonalDataSurface.ApplicationQuery, typeof(GetOwnWorkspaceStaffOnboardingQuery)); + yield return ( + PersonalDataSurface.ApplicationQuery, + typeof(GetWorkspaceStaffIdentityAnchorSweepStatusQuery)); + yield return ( + PersonalDataSurface.ProjectionExport, + typeof(WorkspaceStaffIdentityAnchorSweepCandidate)); + yield return ( + PersonalDataSurface.ProjectionExport, + typeof(WorkspaceStaffIdentityAnchorSweepPage)); + yield return ( + PersonalDataSurface.ProjectionExport, + typeof(WorkspaceStaffIdentityAnchorSweepCandidateResult)); + yield return ( + PersonalDataSurface.ProjectionExport, + typeof(WorkspaceStaffIdentityAnchorSourceRecord)); + yield return ( + PersonalDataSurface.ProjectionExport, + typeof(WorkspaceStaffIdentityAnchorSourcePage)); yield return ( PersonalDataSurface.ApplicationQuery, typeof( @@ -276,10 +493,21 @@ private static Type[] PersistenceTypes() => PersonalDataSurface.AdminOutput, typeof( WorkspaceStaffOnboardingProcessingRestrictionReceiptDto)); + yield return ( + PersonalDataSurface.AdminOutput, + typeof(WorkspaceStaffIdentityAnchorSweepStatus)); + yield return ( + PersonalDataSurface.AdminOutput, + typeof(WorkspaceStaffIdentityAnchorSweepPageCounts)); + yield return ( + PersonalDataSurface.AdminOutput, + typeof(WorkspaceStaffHistoricalNoProvisionDispositionResult)); foreach (Type type in new[] { typeof(WorkspaceStaffOnboardingDataRightsExport), + typeof( + WorkspaceStaffDeferredClaimWithdrawalDataRightsExport), typeof(WorkspaceStaffAccessProcessDataRightsExport), typeof(WorkspaceStaffAccessProfileDataRightsExport), typeof(WorkspaceStaffAccessPlanDataRightsExport), @@ -292,7 +520,9 @@ private static Type[] PersistenceTypes() => typeof( WorkspaceStaffOnboardingProcessingRestrictionDataRightsExport), typeof( - WorkspaceStaffOnboardingProcessingRestrictionReceiptDataRightsExport) + WorkspaceStaffOnboardingProcessingRestrictionReceiptDataRightsExport), + typeof( + WorkspaceStaffHistoricalNoProvisionReceiptDataRightsExport) }) { yield return (PersonalDataSurface.DataRightsExport, type); @@ -306,6 +536,14 @@ private static Type[] PersistenceTypes() => PersonalDataSurface.DomainEvent, typeof( WorkspaceStaffOnboardingProcessingRestrictionChangedDomainEvent)); + yield return ( + PersonalDataSurface.DomainEvent, + typeof( + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent)); + yield return ( + PersonalDataSurface.DomainEvent, + typeof( + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent)); foreach (Type type in new[] { @@ -318,6 +556,10 @@ private static Type[] PersistenceTypes() => typeof(OrganizationEnrollmentLinkExpiredIntegrationEvent), typeof(OrganizationMembershipChangedIntegrationEvent), typeof(StaffMemberLifecycleChangedIntegrationEvent), + typeof( + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent), + typeof( + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent), typeof( WorkspaceStaffOnboardingProcessingRestrictionChangedIntegrationEvent) }) @@ -326,6 +568,35 @@ private static Type[] PersistenceTypes() => } } + private static IEnumerable<( + PersonalDataSurface Surface, + Type Type, + string Method)> BoundaryMethods() + { + yield return ( + PersonalDataSurface.ApplicationQuery, + typeof( + IWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence), + nameof( + IWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence + .CanMutateAsync)); + yield return ( + PersonalDataSurface.ApplicationQuery, + typeof(IWorkspaceStaffHistoricalNoProvisionReceiptRepository), + nameof(IWorkspaceStaffHistoricalNoProvisionReceiptRepository + .FindByOperationIdAsync)); + yield return ( + PersonalDataSurface.ApplicationQuery, + typeof(IWorkspaceStaffHistoricalNoProvisionReceiptRepository), + nameof(IWorkspaceStaffHistoricalNoProvisionReceiptRepository + .FindByApplicationIdAsync)); + yield return ( + PersonalDataSurface.ApplicationCommand, + typeof(IWorkspaceStaffHistoricalNoProvisionReceiptRepository), + nameof(IWorkspaceStaffHistoricalNoProvisionReceiptRepository + .AddAsync)); + } + private static Dictionary CreateAssemblyIndex() => new[] { diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Domain/WorkspaceStaffIdentityAnchorSweepCheckpointTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Domain/WorkspaceStaffIdentityAnchorSweepCheckpointTests.cs new file mode 100644 index 00000000..2fd593ba --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Domain/WorkspaceStaffIdentityAnchorSweepCheckpointTests.cs @@ -0,0 +1,173 @@ +namespace BunkFy.Modules.Workspaces.Tests.Domain; + +using BunkFy.Modules.Workspaces.Domain; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class WorkspaceStaffIdentityAnchorSweepCheckpointTests +{ + private static readonly DateTimeOffset Now = + new(2026, 8, 11, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void Advance_is_exactly_idempotent_and_cycles_without_a_lifetime_cap() + { + const long upper = 30; + const long firstCursor = 20; + Guid cycleId = Guid.NewGuid(); + Guid runId = Guid.NewGuid(); + WorkspaceStaffIdentityAnchorSweepCheckpoint checkpoint = + WorkspaceStaffIdentityAnchorSweepCheckpoint.Create( + Guid.NewGuid(), + "tenant-a", + Now).Value; + Assert.True(checkpoint.BeginCycle( + cycleId, + upper, + runId, + Now.AddMinutes(1)).IsSuccess); + long expectedVersion = checkpoint.Version; + WorkspaceStaffIdentityAnchorSweepPageCounts firstCounts = new( + ScannedCount: 2, + NoAnchorCount: 1, + RemovedCount: 0, + ObservedCount: 1, + AlreadyObservedCount: 0, + DeferredCount: 0, + ConflictCount: 0, + PassOneCommittedCount: 1, + ResolutionRecordConfirmedCount: 0); + Guid firstAdvanceId = Guid.NewGuid(); + + Assert.True(checkpoint.Advance( + expectedVersion, + cycleId, + expectedAfterOrdinal: null, + firstCursor, + reachedEnd: false, + firstAdvanceId, + runId, + firstCounts, + Now.AddMinutes(2)).IsSuccess); + long advancedVersion = checkpoint.Version; + + Assert.True(checkpoint.Advance( + expectedVersion, + cycleId, + expectedAfterOrdinal: null, + firstCursor, + reachedEnd: false, + firstAdvanceId, + runId, + firstCounts, + Now.AddMinutes(3)).IsSuccess); + Assert.Equal(advancedVersion, checkpoint.Version); + WorkspaceStaffIdentityAnchorSweepPageCounts changedReplay = + firstCounts with { ConflictCount = 1, ObservedCount = 0 }; + Assert.True(checkpoint.Advance( + expectedVersion, + cycleId, + expectedAfterOrdinal: null, + firstCursor, + reachedEnd: false, + firstAdvanceId, + runId, + changedReplay, + Now.AddMinutes(3)).IsFailure); + Assert.True(checkpoint.Advance( + expectedVersion, + cycleId, + expectedAfterOrdinal: null, + firstCursor, + reachedEnd: false, + Guid.NewGuid(), + runId, + firstCounts, + Now.AddMinutes(3)).IsFailure); + + WorkspaceStaffIdentityAnchorSweepPageCounts finalCounts = new( + ScannedCount: 1, + NoAnchorCount: 0, + RemovedCount: 0, + ObservedCount: 0, + AlreadyObservedCount: 1, + DeferredCount: 0, + ConflictCount: 0, + PassOneCommittedCount: 0, + ResolutionRecordConfirmedCount: 1); + Assert.True(checkpoint.Advance( + checkpoint.Version, + cycleId, + firstCursor, + upper, + reachedEnd: true, + Guid.NewGuid(), + Guid.NewGuid(), + finalCounts, + Now.AddMinutes(4)).IsSuccess); + + Assert.False(checkpoint.HasActiveCycle); + Assert.Null(checkpoint.AfterOrdinal); + Assert.Equal(cycleId, checkpoint.LastCompletedCycleId); + Assert.Equal(upper, checkpoint.LastCompletedUpperOrdinal); + Assert.Equal(3, checkpoint.LastCompletedScannedCount); + Assert.Equal(1, checkpoint.LastCompletedObservedCount); + Assert.Equal(1, checkpoint.LastCompletedAlreadyObservedCount); + Assert.Equal(2, checkpoint.LastCompletedCounts().SettledCount); + + Guid nextCycleId = Guid.NewGuid(); + Assert.True(checkpoint.BeginCycle( + nextCycleId, + upperOrdinal: 40, + Guid.NewGuid(), + Now.AddMinutes(5)).IsSuccess); + Assert.True(checkpoint.HasActiveCycle); + Assert.Equal(nextCycleId, checkpoint.CycleId); + Assert.Null(checkpoint.AfterOrdinal); + Assert.Equal(0, checkpoint.CycleScannedCount); + } + + [Fact] + public void Empty_cycle_replay_is_bound_to_the_same_cycle_and_run() + { + WorkspaceStaffIdentityAnchorSweepCheckpoint checkpoint = + WorkspaceStaffIdentityAnchorSweepCheckpoint.Create( + Guid.NewGuid(), + "tenant-a", + Now).Value; + Guid cycleId = Guid.NewGuid(); + Guid advanceId = Guid.NewGuid(); + Guid runId = Guid.NewGuid(); + + Assert.True(checkpoint.CompleteEmptyCycle( + cycleId, + advanceId, + runId, + Now.AddMinutes(1)).IsSuccess); + long completedVersion = checkpoint.Version; + Assert.True(checkpoint.CompleteEmptyCycle( + cycleId, + advanceId, + runId, + Now.AddMinutes(2)).IsSuccess); + Assert.Equal(completedVersion, checkpoint.Version); + Assert.True(checkpoint.CompleteEmptyCycle( + Guid.NewGuid(), + advanceId, + runId, + Now.AddMinutes(2)).IsFailure); + Assert.NotNull(checkpoint.LastCompletedAtUtc); + Assert.Equal(0, checkpoint.LastCompletedCounts().BacklogCount); + + Assert.True(checkpoint.BeginCycle( + Guid.NewGuid(), + upperOrdinal: 10, + Guid.NewGuid(), + Now.AddMinutes(3)).IsSuccess); + Assert.True(checkpoint.CompleteEmptyCycle( + cycleId, + advanceId, + runId, + Now.AddMinutes(4)).IsFailure); + } +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Domain/WorkspaceStaffOnboardingIdentityAnchorResolutionTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Domain/WorkspaceStaffOnboardingIdentityAnchorResolutionTests.cs new file mode 100644 index 00000000..af978be9 --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Domain/WorkspaceStaffOnboardingIdentityAnchorResolutionTests.cs @@ -0,0 +1,505 @@ +namespace BunkFy.Modules.Workspaces.Tests; + +using System.Reflection; +using BunkFy.Modules.Workspaces.Application.Handlers; +using BunkFy.Modules.Workspaces.Contracts; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Domain.Events; +using Gma.Framework.Messaging; +using Xunit; +using ContractResolutionDisposition = + BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition; +using DomainResolutionDisposition = + BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition; + +[Trait("Category", "Unit")] +public sealed class WorkspaceStaffOnboardingIdentityAnchorResolutionTests +{ + [Fact] + public void Identity_anchor_uses_explicit_distinct_random_coordinates() + { + Guid applicationId = + Guid.Parse("10000000-0000-0000-0000-000000000001"); + AnchorCoordinates coordinates = NewAnchorCoordinates(applicationId); + WorkspaceStaffOnboarding application = CreateProvisioningApplication( + applicationId); + Assert.True(application.MarkStaffReady( + StaffMemberId, + coordinates.ResolutionEventId, + coordinates.ContinuationEventId, + Now.AddMinutes(2)).IsSuccess); + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent + continuation = Assert.IsType< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent>( + Assert.Single(application.DomainEvents)); + application.ClearDomainEvents(); + Assert.True(application.Complete(Now.AddMinutes(3)).IsSuccess); + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent resolution = + Assert.IsType< + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent>( + Assert.Single(application.DomainEvents)); + Assert.Equal( + coordinates.ContinuationEventId, + continuation.EventId); + Assert.Equal( + coordinates.ResolutionEventId, + resolution.EventId); + Assert.Equal( + coordinates.ResolutionEventId, + application.IdentityAnchorExpectedResolutionEventId); + Assert.Equal( + coordinates.ContinuationEventId, + application.IdentityAnchorContinuationEventId); + Assert.NotEqual(applicationId, coordinates.ContinuationEventId); + Assert.NotEqual(applicationId, coordinates.ResolutionEventId); + Assert.NotEqual( + coordinates.ContinuationEventId, + coordinates.ResolutionEventId); + _ = new WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent( + coordinates.ResolutionEventId, + WorkspaceStaffOnboardingTests.OrganizationId.ToString("D"), + Now, + applicationId, + StaffMemberId, + workspaceApplicationVersion: 1, + ContractResolutionDisposition.CompletedRedacted); + Assert.Throws(() => + new WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent( + applicationId, + WorkspaceStaffOnboardingTests.OrganizationId.ToString("D"), + Now, + applicationId, + StaffMemberId, + workspaceApplicationVersion: 1, + ContractResolutionDisposition.CompletedRedacted)); + } + + [Fact] + public void Staff_ready_redacts_the_redundant_applicant_profile_immediately() + { + WorkspaceStaffOnboarding application = CreateProvisioningApplication(); + + AnchorCoordinates coordinates = BindStaffAnchor(application); + + Assert.Equal(WorkspaceStaffOnboardingState.StaffReady, application.Status); + Assert.Equal(StaffMemberId, application.StaffMemberId); + AssertApplicantDataRedacted(application); + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent + continuation = Assert.IsType< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent>( + Assert.Single(application.DomainEvents)); + Assert.NotEqual(application.Id, continuation.EventId); + Assert.Equal(coordinates.ContinuationEventId, continuation.EventId); + Assert.Equal( + coordinates.ResolutionEventId, + application.IdentityAnchorExpectedResolutionEventId); + } + + [Fact] + public void Resolution_intent_replay_uses_the_captured_time_and_emits_once() + { + WorkspaceStaffOnboarding application = CreateProvisioningApplication(); + AnchorCoordinates coordinates = BindStaffAnchor(application); + application.ClearDomainEvents(); + Assert.True(application.Complete(Now.AddMinutes(3)).IsSuccess); + long capturedVersion = application.Version; + DateTimeOffset? capturedAt = + application.IdentityAnchorResolutionIntentAtUtc; + + Assert.True(application.RecordResolutionIntent( + StaffMemberId, + DomainResolutionDisposition.CompletedRedacted, + Now.AddMinutes(30)).IsSuccess); + + Assert.Equal(capturedVersion, application.Version); + Assert.Equal(capturedAt, application.IdentityAnchorResolutionIntentAtUtc); + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent domainEvent = + Assert.IsType< + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent>( + Assert.Single(application.DomainEvents)); + Assert.Equal( + coordinates.ResolutionEventId, + domainEvent.EventId); + Assert.NotEqual(application.Id, domainEvent.EventId); + Assert.Equal(application.Id, domainEvent.ApplicationId); + Assert.Equal(StaffMemberId, domainEvent.StaffMemberId); + Assert.Equal(capturedVersion, domainEvent.WorkspaceApplicationVersion); + } + + [Fact] + public void Resolution_observation_advances_once_and_preserves_the_captured_version() + { + WorkspaceStaffOnboarding application = CreateProvisioningApplication(); + _ = BindStaffAnchor(application); + application.ClearDomainEvents(); + Assert.True(application.Complete(Now.AddMinutes(3)).IsSuccess); + long capturedVersion = application.Version; + DateTimeOffset firstObservedAt = Now.AddMinutes(4); + + Assert.True(application.ObserveResolution( + application.IdentityAnchorResolutionEventId!.Value, + StaffMemberId, + capturedVersion, + DomainResolutionDisposition.CompletedRedacted, + firstObservedAt).IsSuccess); + long observedVersion = application.Version; + Assert.True(application.ObserveResolution( + application.IdentityAnchorResolutionEventId!.Value, + StaffMemberId, + capturedVersion, + DomainResolutionDisposition.CompletedRedacted, + Now.AddMinutes(10)).IsSuccess); + + Assert.Equal(capturedVersion + 1, observedVersion); + Assert.Equal(observedVersion, application.Version); + Assert.Equal( + capturedVersion, + application.IdentityAnchorResolutionApplicationVersion); + Assert.Equal( + firstObservedAt, + application.IdentityAnchorResolutionObservedAtUtc); + } + + [Fact] + public void Resolution_observation_normalizes_a_backward_clock_to_causal_time() + { + WorkspaceStaffOnboarding application = CreateProvisioningApplication(); + _ = BindStaffAnchor(application); + application.ClearDomainEvents(); + Assert.True(application.Complete(Now.AddMinutes(3)).IsSuccess); + long capturedVersion = + application.IdentityAnchorResolutionApplicationVersion!.Value; + DateTimeOffset intentAt = + application.IdentityAnchorResolutionIntentAtUtc!.Value; + + Assert.True(application.ObserveResolution( + application.IdentityAnchorResolutionEventId!.Value, + StaffMemberId, + capturedVersion, + DomainResolutionDisposition.CompletedRedacted, + Now.AddHours(-1)).IsSuccess); + + Assert.Equal(intentAt, application.IdentityAnchorResolutionObservedAtUtc); + Assert.Equal(intentAt, application.LastChangedAtUtc); + } + + [Fact] + public void Resolution_observation_uses_a_later_local_change_as_its_causal_floor() + { + WorkspaceStaffOnboarding application = CreateProvisioningApplication(); + _ = BindStaffAnchor(application); + application.ClearDomainEvents(); + Assert.True(application.Complete(Now.AddMinutes(3)).IsSuccess); + long capturedVersion = + application.IdentityAnchorResolutionApplicationVersion!.Value; + DateTimeOffset laterLocalChange = Now.AddMinutes(8); + SetPropertyForMaterialization( + application, + nameof(WorkspaceStaffOnboarding.LastChangedAtUtc), + laterLocalChange); + SetPropertyForMaterialization( + application, + nameof(WorkspaceStaffOnboarding.Version), + capturedVersion + 1); + + Assert.True(application.ObserveResolution( + application.IdentityAnchorResolutionEventId!.Value, + StaffMemberId, + capturedVersion, + DomainResolutionDisposition.CompletedRedacted, + Now.AddMinutes(4)).IsSuccess); + + Assert.Equal( + laterLocalChange, + application.IdentityAnchorResolutionObservedAtUtc); + Assert.Equal(capturedVersion + 2, application.Version); + } + + [Fact] + public void Undefined_or_divergent_resolution_coordinates_fail_without_mutation() + { + WorkspaceStaffOnboarding application = CreateProvisioningApplication(); + _ = BindStaffAnchor(application); + application.ClearDomainEvents(); + Assert.True(application.Complete(Now.AddMinutes(3)).IsSuccess); + long capturedVersion = + application.IdentityAnchorResolutionApplicationVersion!.Value; + long version = application.Version; + + Assert.True(application.RecordResolutionIntent( + StaffMemberId, + (DomainResolutionDisposition)99, + Now.AddMinutes(4)).IsFailure); + Assert.True(application.ObserveResolution( + application.IdentityAnchorResolutionEventId!.Value, + StaffMemberId, + capturedVersion + 1, + DomainResolutionDisposition.CompletedRedacted, + Now.AddMinutes(4)).IsFailure); + Assert.True(application.ObserveResolution( + application.IdentityAnchorResolutionEventId!.Value, + StaffMemberId, + capturedVersion, + DomainResolutionDisposition.SupersededRedacted, + Now.AddMinutes(4)).IsFailure); + Assert.True(application.ObserveResolution( + application.IdentityAnchorResolutionEventId!.Value, + StaffMemberId, + capturedVersion, + (DomainResolutionDisposition)99, + Now.AddMinutes(4)).IsFailure); + + Assert.Equal(version, application.Version); + Assert.Null(application.IdentityAnchorResolutionObservedAtUtc); + } + + [Fact] + public void Completed_legacy_replay_does_not_manufacture_resolution_intent() + { + WorkspaceStaffOnboarding application = CreateProvisioningApplication(); + _ = BindStaffAnchor(application); + application.ClearDomainEvents(); + SetStatusForLegacyMaterialization( + application, + WorkspaceStaffOnboardingState.Completed); + long version = application.Version; + + Assert.True(application.Complete(Now.AddMinutes(20)).IsSuccess); + + Assert.Equal(version, application.Version); + Assert.Null(application.IdentityAnchorResolutionEventId); + Assert.Null(application.IdentityAnchorResolutionIntentAtUtc); + Assert.Empty(application.DomainEvents); + } + + [Fact] + public async Task Resolution_event_projects_one_privacy_minimal_outbox_fact() + { + WorkspaceStaffOnboarding application = CreateProvisioningApplication(); + AnchorCoordinates coordinates = BindStaffAnchor(application); + application.ClearDomainEvents(); + Assert.True(application.Complete(Now.AddMinutes(3)).IsSuccess); + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent domainEvent = + Assert.IsType< + WorkspaceStaffOnboardingIdentityAnchorResolvedDomainEvent>( + Assert.Single(application.DomainEvents)); + RecordingOutbox outbox = new(); + + await new + WorkspaceStaffOnboardingIdentityAnchorResolutionOutboxProjector( + new RecordingOutboxRegistry(outbox)) + .HandleAsync(domainEvent, CancellationToken.None); + + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent + integrationEvent = Assert.IsType< + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent>( + Assert.Single(outbox.Events)); + Assert.Equal( + coordinates.ResolutionEventId, + integrationEvent.EventId); + Assert.NotEqual(application.Id, integrationEvent.EventId); + Assert.NotEqual( + coordinates.ContinuationEventId, + integrationEvent.EventId); + Assert.Equal(application.Id, integrationEvent.ApplicationId); + Assert.Equal(StaffMemberId, integrationEvent.StaffMemberId); + Assert.Equal( + application.IdentityAnchorResolutionApplicationVersion, + integrationEvent.WorkspaceApplicationVersion); + Assert.Equal( + ContractResolutionDisposition.CompletedRedacted, + integrationEvent.Disposition); + string[] forbiddenProperties = + [ + "SubjectId", + "DisplayName", + "LegalName", + "WorkEmail", + "WorkPhone", + "EmployeeNumber", + "JobTitle", + "Department" + ]; + Assert.DoesNotContain( + integrationEvent.GetType().GetProperties(), + property => forbiddenProperties.Contains( + property.Name, + StringComparer.OrdinalIgnoreCase)); + } + + [Fact] + public async Task Repeated_anchor_convergence_is_idempotent_for_the_persisted_supplied_coordinates() + { + Guid applicationId = Guid.NewGuid(); + WorkspaceStaffOnboarding application = CreateProvisioningApplication( + applicationId); + AnchorCoordinates coordinates = NewAnchorCoordinates(applicationId); + + Assert.True(application.ConvergeCommittedStaffAnchor( + StaffMemberId, + coordinates.ResolutionEventId, + coordinates.ContinuationEventId, + Now.AddMinutes(2)).IsSuccess); + long convergedVersion = application.Version; + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent + continuation = Assert.IsType< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedDomainEvent>( + Assert.Single(application.DomainEvents)); + application.ClearDomainEvents(); + + Assert.True(application.ConvergeCommittedStaffAnchor( + StaffMemberId, + coordinates.ResolutionEventId, + coordinates.ContinuationEventId, + Now.AddMinutes(20)).IsSuccess); + + Assert.Equal(convergedVersion, application.Version); + Assert.Equal( + coordinates.ResolutionEventId, + application.IdentityAnchorExpectedResolutionEventId); + Assert.Equal( + coordinates.ContinuationEventId, + application.IdentityAnchorContinuationEventId); + Assert.Equal(coordinates.ContinuationEventId, continuation.EventId); + Assert.Empty(application.DomainEvents); + + RecordingOutbox outbox = new(); + await new + WorkspaceStaffOnboardingIdentityAnchorContinuationOutboxProjector( + new RecordingOutboxRegistry(outbox)) + .HandleAsync(continuation, CancellationToken.None); + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + integrationEvent = Assert.IsType< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent>( + Assert.Single(outbox.Events)); + Assert.Equal(continuation.EventId, integrationEvent.EventId); + Assert.Equal(applicationId, integrationEvent.ApplicationId); + Assert.Equal(StaffMemberId, integrationEvent.StaffMemberId); + } + + private static AnchorCoordinates BindStaffAnchor( + WorkspaceStaffOnboarding application) + { + AnchorCoordinates coordinates = NewAnchorCoordinates(application.Id); + Assert.True(application.MarkStaffReady( + StaffMemberId, + coordinates.ResolutionEventId, + coordinates.ContinuationEventId, + Now.AddMinutes(2)).IsSuccess); + return coordinates; + } + + private static AnchorCoordinates NewAnchorCoordinates( + Guid applicationId) + { + Guid resolutionEventId = NewDistinctId(applicationId); + Guid continuationEventId = NewDistinctId( + applicationId, + resolutionEventId); + return new(resolutionEventId, continuationEventId); + } + + private static Guid NewDistinctId(params Guid[] excluded) + { + Guid candidate; + do + { + candidate = Guid.NewGuid(); + } + while (candidate == Guid.Empty || excluded.Contains(candidate)); + + return candidate; + } + + private static WorkspaceStaffOnboarding CreateProvisioningApplication( + Guid? applicationId = null) + { + WorkspaceStaffOnboarding application = WorkspaceStaffOnboarding.Create( + applicationId ?? Guid.NewGuid(), + WorkspaceStaffOnboardingTests.OrganizationId.ToString("D"), + WorkspaceStaffOnboardingSource.EnrollmentLink, + Guid.NewGuid(), + WorkspaceStaffOnboardingTests.SubjectId, + "verified@example.test", + "Ada Operator", + "Ada Lovelace", + "ada@workspace.test", + "+1 555 0100", + "EMP-100", + "Manager", + "Operations", + Now).Value; + Assert.True(application.ObserveClaimAccepted( + Guid.NewGuid(), + 1, + Now.AddMinutes(1)).IsSuccess); + return application; + } + + private static void SetStatusForLegacyMaterialization( + WorkspaceStaffOnboarding application, + WorkspaceStaffOnboardingState status) => + SetPropertyForMaterialization( + application, + nameof(WorkspaceStaffOnboarding.Status), + status); + + private static void SetPropertyForMaterialization( + WorkspaceStaffOnboarding application, + string propertyName, + object value) => + typeof(WorkspaceStaffOnboarding) + .GetProperty( + propertyName, + BindingFlags.Instance | + BindingFlags.Public | + BindingFlags.NonPublic)! + .SetValue(application, value); + + private static void AssertApplicantDataRedacted( + WorkspaceStaffOnboarding application) + { + Assert.Null(application.VerifiedAccountEmail); + Assert.Null(application.DisplayName); + Assert.Null(application.LegalName); + Assert.Null(application.WorkEmail); + Assert.Null(application.WorkPhone); + Assert.Null(application.EmployeeNumber); + Assert.Null(application.JobTitle); + Assert.Null(application.Department); + } + + private sealed class RecordingOutbox : IOutboxWriter + { + public string ModuleName => WorkspacesModuleMetadata.Name; + public List Events { get; } = []; + + public Task EnqueueAsync( + TEvent integrationEvent, + CancellationToken cancellationToken) + where TEvent : IIntegrationEvent + { + this.Events.Add(integrationEvent); + return Task.CompletedTask; + } + } + + private sealed class RecordingOutboxRegistry(RecordingOutbox outbox) + : IOutboxWriterRegistry + { + public IOutboxWriter GetRequired(string moduleName) + { + Assert.Equal(WorkspacesModuleMetadata.Name, moduleName); + return outbox; + } + } + + private readonly record struct AnchorCoordinates( + Guid ResolutionEventId, + Guid ContinuationEventId); + + private static readonly Guid StaffMemberId = + Guid.Parse("30000000-0000-0000-0000-000000000003"); + private static readonly DateTimeOffset Now = + WorkspaceStaffOnboardingTests.Now; +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Domain/WorkspaceStaffOnboardingProcessingRestrictionTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Domain/WorkspaceStaffOnboardingProcessingRestrictionTests.cs index 902c91bc..f068397a 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Domain/WorkspaceStaffOnboardingProcessingRestrictionTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Domain/WorkspaceStaffOnboardingProcessingRestrictionTests.cs @@ -23,6 +23,8 @@ public void Applicant_authority_ends_when_staff_is_created() Assert.True(onboarding.HasApplicantAuthority); Assert.True(onboarding.MarkStaffReady( + Guid.NewGuid(), + Guid.NewGuid(), Guid.NewGuid(), Now.AddMinutes(2)).IsSuccess); Assert.False(onboarding.HasApplicantAuthority); 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..0fb904d6 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() { @@ -15,7 +44,11 @@ public void Completion_requires_staff_and_redacts_applicant_data() Guid staffMemberId = Guid.NewGuid(); Assert.True(application.ObserveClaimAccepted(claimId, 1, Now.AddMinutes(1)).IsSuccess); - Assert.True(application.MarkStaffReady(staffMemberId, Now.AddMinutes(2)).IsSuccess); + Assert.True(application.MarkStaffReady( + staffMemberId, + Guid.NewGuid(), + Guid.NewGuid(), + Now.AddMinutes(2)).IsSuccess); Assert.True(application.Complete(Now.AddMinutes(3)).IsSuccess); Assert.Equal(WorkspaceStaffOnboardingState.Completed, application.Status); @@ -70,7 +103,11 @@ public void Failed_staff_ready_work_can_reenter_provisioning_without_losing_staf Guid claimId = Guid.NewGuid(); Guid staffMemberId = Guid.NewGuid(); Assert.True(application.ObserveClaimAccepted(claimId, 1, Now.AddMinutes(1)).IsSuccess); - Assert.True(application.MarkStaffReady(staffMemberId, Now.AddMinutes(2)).IsSuccess); + Assert.True(application.MarkStaffReady( + staffMemberId, + Guid.NewGuid(), + Guid.NewGuid(), + Now.AddMinutes(2)).IsSuccess); Assert.True(application.Fail("Workspaces.AccessProvisioningFailed", Now.AddMinutes(3)).IsSuccess); Assert.True(application.BeginProvisioning(Now.AddMinutes(4)).IsSuccess); diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffCorrelationAnonymisationRepositoryTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffCorrelationAnonymisationRepositoryTests.cs index 2736dddc..b2386160 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffCorrelationAnonymisationRepositoryTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffCorrelationAnonymisationRepositoryTests.cs @@ -431,6 +431,8 @@ private static WorkspaceStaffOnboarding Now.AddMinutes(1)).IsSuccess); Assert.True(onboarding.MarkStaffReady( StaffMemberId, + Guid.NewGuid(), + Guid.NewGuid(), Now.AddMinutes(2)).IsSuccess); Assert.True(onboarding.Complete( Now.AddMinutes(3)).IsSuccess); diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffHistoricalNoProvisionReceiptTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffHistoricalNoProvisionReceiptTests.cs new file mode 100644 index 00000000..a5030f93 --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffHistoricalNoProvisionReceiptTests.cs @@ -0,0 +1,336 @@ +namespace BunkFy.Modules.Workspaces.Tests.Persistence; + +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Persistence; +using BunkFy.Modules.Workspaces.Persistence.Repositories; +using Gma.Framework.Cqrs; +using Gma.Framework.Scoping; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class WorkspaceStaffHistoricalNoProvisionReceiptTests +{ + private const string TenantId = + "aaaaaaaa-1111-1111-1111-111111111111"; + private static readonly DateTimeOffset ReviewedAtUtc = new( + 2026, + 8, + 11, + 16, + 0, + 0, + TimeSpan.Zero); + + [Fact] + public void Model_is_scoped_append_only_and_uniquely_binds_operation_and_app() + { + using WorkspacesDbContext context = CreateContext(); + Microsoft.EntityFrameworkCore.Metadata.IEntityType entity = context + .Model.FindEntityType( + typeof(WorkspaceStaffHistoricalNoProvisionReceipt))!; + + Assert.Equal( + "staff_historical_no_provision_receipts", + entity.GetTableName()); + Assert.NotEmpty(entity.GetDeclaredQueryFilters()); + Assert.Contains(entity.GetIndexes(), index => + index.IsUnique && + index.Properties.Select(property => property.Name).SequenceEqual([ + nameof(WorkspaceStaffHistoricalNoProvisionReceipt.ScopeId), + nameof(WorkspaceStaffHistoricalNoProvisionReceipt.OperationId) + ])); + Assert.Contains(entity.GetIndexes(), index => + index.IsUnique && + index.Properties.Select(property => property.Name).SequenceEqual([ + nameof(WorkspaceStaffHistoricalNoProvisionReceipt.ScopeId), + nameof(WorkspaceStaffHistoricalNoProvisionReceipt.ApplicationId) + ])); + Assert.Contains(entity.GetIndexes(), index => + index.Properties.Select(property => property.Name).SequenceEqual([ + nameof(WorkspaceStaffHistoricalNoProvisionReceipt.ScopeId), + nameof(WorkspaceStaffHistoricalNoProvisionReceipt.SourceKind), + nameof(WorkspaceStaffHistoricalNoProvisionReceipt.SourceId) + ])); + } + + [Fact] + public async Task Persisted_receipt_cannot_be_modified_or_deleted() + { + await using WorkspacesDbContext context = CreateContext(); + WorkspaceStaffHistoricalNoProvisionReceipt receipt = CreateReceipt(); + context.StaffHistoricalNoProvisionReceipts.Add(receipt); + await context.SaveChangesAsync(); + + context.Entry(receipt).State = EntityState.Modified; + InvalidOperationException modified = + await Assert.ThrowsAsync( + () => context.SaveChangesAsync()); + Assert.Equal( + "Workspace immutable receipts are append-only.", + modified.Message); + + context.Entry(receipt).State = EntityState.Unchanged; + context.StaffHistoricalNoProvisionReceipts.Remove(receipt); + InvalidOperationException deleted = + await Assert.ThrowsAsync( + () => context.SaveChangesAsync()); + Assert.Equal( + "Workspace immutable receipts are append-only.", + deleted.Message); + } + + [Fact] + public void Exact_review_exclusion_translates_the_receipt_derived_pseudonym_for_postgresql() + { + using WorkspacesDbContext context = new( + new DbContextOptionsBuilder() + .UseNpgsql( + "Host=localhost;Database=translation_only;" + + "Username=translation_only;Password=translation_only") + .Options, + new TestScopeContext()); + + string sql = context.StaffOnboardingApplications + .ExcludeExactlyReviewed(context) + .ToQueryString(); + + Assert.Contains( + WorkspaceStaffHistoricalNoProvisionReceipt + .SubjectPseudonymPrefix, + sql, + StringComparison.Ordinal); + Assert.Contains("::text", sql, StringComparison.Ordinal); + Assert.Contains( + nameof(WorkspaceStaffOnboarding.DisplayName), + sql, + StringComparison.Ordinal); + Assert.Contains( + nameof(WorkspaceStaffOnboarding + .IdentityAnchorResolutionObservedAtUtc), + sql, + StringComparison.Ordinal); + } + + [Fact] + public void Receipt_proof_is_exact_and_rejects_version_overflow() + { + WorkspaceStaffHistoricalNoProvisionReceipt receipt = CreateReceipt(); + + Assert.True(receipt.HasValidCanonicalProof()); + Assert.True(receipt.MatchesReplay( + TenantId, + receipt.ApplicationId, + receipt.ExpectedApplicationVersion, + receipt.ExpectedApplicationStatus, + receipt.OrganizationsScopeRevision, + receipt.OrganizationsSourceVersion, + receipt.OrganizationsSourceStatus, + receipt.ExternalEvidenceManifestId, + receipt.ExternalEvidenceSha256, + receipt.ReviewerId)); + Assert.False(receipt.MatchesReplay( + TenantId, + receipt.ApplicationId, + receipt.ExpectedApplicationVersion, + receipt.ExpectedApplicationStatus, + receipt.OrganizationsScopeRevision, + receipt.OrganizationsSourceVersion, + receipt.OrganizationsSourceStatus, + receipt.ExternalEvidenceManifestId, + new string('c', 64), + receipt.ReviewerId)); + + Assert.True(WorkspaceStaffHistoricalNoProvisionReceipt.Create( + Guid.NewGuid(), + TenantId, + Guid.NewGuid(), + Guid.NewGuid(), + WorkspaceStaffOnboardingSource.Invitation, + Guid.NewGuid(), + long.MaxValue, + WorkspaceStaffOnboardingState.Submitted, + long.MinValue, + WorkspaceStaffOnboardingState.Superseded, + organizationsScopeRevision: 1, + organizationsSourceVersion: 1, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationRevoked, + new string('a', 64), + Guid.NewGuid(), + new string('b', 64), + "operator:reviewer", + ReviewedAtUtc).IsFailure); + } + + [Fact] + public void Receipt_timestamp_is_canonical_at_postgresql_precision_before_hashing() + { + DateTimeOffset subMicrosecond = ReviewedAtUtc.AddTicks(9); + + WorkspaceStaffHistoricalNoProvisionReceipt receipt = + WorkspaceStaffHistoricalNoProvisionReceipt.Create( + Guid.Parse("11111111-2222-3333-4444-555555555556"), + TenantId, + Guid.Parse("22222222-2222-3333-4444-555555555556"), + Guid.Parse("33333333-2222-3333-4444-555555555556"), + WorkspaceStaffOnboardingSource.Invitation, + Guid.Parse("44444444-2222-3333-4444-555555555556"), + expectedApplicationVersion: 3, + WorkspaceStaffOnboardingState.Superseded, + resultApplicationVersion: 4, + WorkspaceStaffOnboardingState.Superseded, + organizationsScopeRevision: 5, + organizationsSourceVersion: 6, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationRevoked, + new string('a', 64), + Guid.Parse("55555555-2222-3333-4444-555555555556"), + new string('b', 64), + "operator:reviewer", + subMicrosecond).Value; + + Assert.Equal(0, receipt.ReviewedAtUtc.Ticks % 10); + Assert.Equal(ReviewedAtUtc, receipt.ReviewedAtUtc); + Assert.True(receipt.HasValidCanonicalProof()); + } + + [Theory] + [InlineData( + WorkspaceStaffOnboardingSource.Invitation, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus.InvitationRevoked, + true)] + [InlineData( + WorkspaceStaffOnboardingSource.Invitation, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus.InvitationSuperseded, + true)] + [InlineData( + WorkspaceStaffOnboardingSource.Invitation, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus.InvitationExpired, + true)] + [InlineData( + WorkspaceStaffOnboardingSource.Invitation, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus.InvitationPending, + false)] + [InlineData( + WorkspaceStaffOnboardingSource.Invitation, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus.InvitationAccepted, + false)] + [InlineData( + WorkspaceStaffOnboardingSource.EnrollmentLink, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus.EnrollmentLinkDisabled, + true)] + [InlineData( + WorkspaceStaffOnboardingSource.EnrollmentLink, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus.EnrollmentLinkRotated, + true)] + [InlineData( + WorkspaceStaffOnboardingSource.EnrollmentLink, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus.EnrollmentLinkExpired, + true)] + [InlineData( + WorkspaceStaffOnboardingSource.EnrollmentLink, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus.EnrollmentLinkActive, + false)] + [InlineData( + WorkspaceStaffOnboardingSource.EnrollmentLink, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus.EnrollmentLinkCapacityReached, + false)] + public void Terminal_authority_allowlist_is_exact( + WorkspaceStaffOnboardingSource sourceKind, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus status, + bool expected) + { + Assert.Equal( + expected, + WorkspaceStaffHistoricalNoProvisionReceipt.IsTerminalAuthority( + sourceKind, + status)); + } + + [Fact] + public void Authority_status_contract_ordinals_are_stable() + { + Assert.Equal( + Enumerable.Range(0, 11), + Enum.GetValues< + WorkspaceStaffHistoricalNoProvisionAuthorityStatus>() + .Select(status => (int)status)); + } + + [Fact] + public void Persistence_registers_the_scoped_repository_and_dbset() + { + ServiceCollection applicationServices = new(); + applicationServices.AddWorkspacesApplication( + new ConfigurationBuilder().Build(), + "global"); + ServiceDescriptor handler = Assert.Single( + applicationServices, + descriptor => descriptor.ServiceType == typeof(ICommandHandler< + ReviewWorkspaceStaffHistoricalNoProvisionCommand, + WorkspaceStaffHistoricalNoProvisionDispositionResult>)); + Assert.Equal( + "ReviewWorkspaceStaffHistoricalNoProvisionCommandHandler", + handler.ImplementationType?.Name); + Assert.Equal(ServiceLifetime.Scoped, handler.Lifetime); + + HostApplicationBuilder builder = Host.CreateApplicationBuilder([]); + builder.Configuration["Persistence:Provider"] = "PostgreSql"; + builder.Configuration["ConnectionStrings:PostgreSql"] = + "Host=localhost;Database=unused;Username=unused;Password=unused"; + + builder.AddWorkspacesPersistence(); + + ServiceDescriptor registration = Assert.Single( + builder.Services, + descriptor => descriptor.ServiceType == + typeof(IWorkspaceStaffHistoricalNoProvisionReceiptRepository)); + Assert.Equal( + "WorkspaceStaffHistoricalNoProvisionReceiptRepository", + registration.ImplementationType?.Name); + Assert.Equal(ServiceLifetime.Scoped, registration.Lifetime); + Assert.NotNull(typeof(WorkspacesDbContext).GetProperty( + nameof(WorkspacesDbContext.StaffHistoricalNoProvisionReceipts))); + } + + private static WorkspacesDbContext CreateContext() => new( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options, + new TestScopeContext()); + + private static WorkspaceStaffHistoricalNoProvisionReceipt CreateReceipt() + => WorkspaceStaffHistoricalNoProvisionReceipt.Create( + Guid.Parse("11111111-2222-3333-4444-555555555555"), + TenantId, + Guid.Parse("22222222-2222-3333-4444-555555555555"), + Guid.Parse("33333333-2222-3333-4444-555555555555"), + WorkspaceStaffOnboardingSource.Invitation, + Guid.Parse("44444444-2222-3333-4444-555555555555"), + expectedApplicationVersion: 3, + WorkspaceStaffOnboardingState.Superseded, + resultApplicationVersion: 4, + WorkspaceStaffOnboardingState.Superseded, + organizationsScopeRevision: 5, + organizationsSourceVersion: 6, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationRevoked, + new string('a', 64), + Guid.Parse("55555555-2222-3333-4444-555555555555"), + new string('b', 64), + "operator:reviewer", + ReviewedAtUtc).Value; + + private sealed class TestScopeContext : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => TenantId; + } +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffIdentityAnchorCutoverSourceReaderTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffIdentityAnchorCutoverSourceReaderTests.cs new file mode 100644 index 00000000..a8e8d035 --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffIdentityAnchorCutoverSourceReaderTests.cs @@ -0,0 +1,178 @@ +namespace BunkFy.Modules.Workspaces.Tests; + +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Persistence; +using BunkFy.Modules.Workspaces.Persistence.Repositories; +using Gma.Framework.Scoping; +using Microsoft.EntityFrameworkCore; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class WorkspaceStaffIdentityAnchorCutoverSourceReaderTests +{ + private const string TenantId = "tenant-a"; + private static readonly DateTimeOffset Now = new( + 2026, + 8, + 11, + 12, + 0, + 0, + TimeSpan.Zero); + + [Fact] + public async Task Full_universe_is_keyset_paged_with_trimmed_subjects_and_null_targets() + { + int count = WorkspaceStaffIdentityAnchorCutoverSourceLimits.PageSize + + 3; + Guid[] completedIds = Enumerable.Range(1, count) + .Select(CreateDeterministicGuid) + .OrderBy(id => id) + .ToArray(); + await using WorkspacesDbContext context = CreateContext(); + context.StaffOnboardingApplications.AddRange( + completedIds + .Reverse() + .Select((id, index) => + CreateCompletedApplication(id, index + 1))); + + WorkspaceStaffOnboarding excluded = CreateApplication( + CreateDeterministicGuid(count + 1), + count + 1); + Assert.True(excluded.Supersede(Now.AddMinutes(1)).IsSuccess); + context.StaffOnboardingApplications.Add(excluded); + await context.SaveChangesAsync(); + Guid[] expectedIds = completedIds + .Append(excluded.Id) + .Order() + .ToArray(); + WorkspaceStaffIdentityAnchorCutoverSourceReader reader = new(context); + + WorkspaceStaffIdentityAnchorSourcePage first = await reader + .ListRelevantPageAsync( + afterApplicationId: null, + WorkspaceStaffIdentityAnchorCutoverSourceLimits.PageSize, + CancellationToken.None); + WorkspaceStaffIdentityAnchorSourcePage second = await reader + .ListRelevantPageAsync( + first.NextApplicationId, + WorkspaceStaffIdentityAnchorCutoverSourceLimits.PageSize, + CancellationToken.None); + WorkspaceStaffIdentityAnchorSourcePage terminal = await reader + .ListRelevantPageAsync( + second.NextApplicationId, + WorkspaceStaffIdentityAnchorCutoverSourceLimits.PageSize, + CancellationToken.None); + + Assert.Equal( + expectedIds.Take( + WorkspaceStaffIdentityAnchorCutoverSourceLimits.PageSize), + first.Records.Select(record => record.ApplicationId)); + Assert.True(first.HasMore); + Assert.Equal(first.Records[^1].ApplicationId, first.NextApplicationId); + Assert.Equal( + expectedIds.Skip( + WorkspaceStaffIdentityAnchorCutoverSourceLimits.PageSize), + second.Records.Select(record => record.ApplicationId)); + Assert.False(second.HasMore); + Assert.Equal( + second.Records[^1].ApplicationId, + second.NextApplicationId); + Assert.Empty(terminal.Records); + Assert.False(terminal.HasMore); + Assert.Equal(second.NextApplicationId, terminal.NextApplicationId); + WorkspaceStaffIdentityAnchorSourceRecord terminalRecord = + Assert.Single( + first.Records.Concat(second.Records), + record => record.ApplicationId == excluded.Id); + Assert.Null(terminalRecord.StaffMemberId); + Assert.Equal( + WorkspaceStaffOnboardingState.Superseded, + terminalRecord.Status); + Assert.Equal($"subject-{count + 1}", terminalRecord.SubjectId); + Assert.All( + first.Records.Concat(second.Records), + record => Assert.Equal(record.SubjectId.Trim(), record.SubjectId)); + } + + [Fact] + public async Task Cursor_and_page_size_envelope_is_enforced() + { + await using WorkspacesDbContext context = CreateContext(); + WorkspaceStaffIdentityAnchorCutoverSourceReader reader = new(context); + + await Assert.ThrowsAsync(() => + reader.ListRelevantPageAsync( + Guid.Empty, + pageSize: 1, + CancellationToken.None)); + await Assert.ThrowsAsync(() => + reader.ListRelevantPageAsync( + afterApplicationId: null, + pageSize: 0, + CancellationToken.None)); + await Assert.ThrowsAsync(() => + reader.ListRelevantPageAsync( + afterApplicationId: null, + WorkspaceStaffIdentityAnchorCutoverSourceLimits.PageSize + 1, + CancellationToken.None)); + } + + private static WorkspacesDbContext CreateContext() => new( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options, + new TestScopeContext()); + + private static WorkspaceStaffOnboarding CreateApplication( + Guid applicationId, + int seed) => + WorkspaceStaffOnboarding.Create( + applicationId, + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + CreateDeterministicGuid(10_000 + seed), + $"subject-{seed}", + $"subject-{seed}@example.test", + $"Subject {seed}", + legalName: null, + workEmail: null, + workPhone: null, + employeeNumber: null, + jobTitle: null, + department: null, + Now).Value; + + private static WorkspaceStaffOnboarding CreateCompletedApplication( + Guid applicationId, + int seed) + { + WorkspaceStaffOnboarding application = CreateApplication( + applicationId, + seed); + Assert.True( + application.ObserveInvitationAccepted(Now.AddMinutes(1)) + .IsSuccess); + Assert.True( + application.MarkStaffReady( + CreateDeterministicGuid(30_000 + seed), + CreateDeterministicGuid(40_000 + seed), + CreateDeterministicGuid(50_000 + seed), + Now.AddMinutes(2)) + .IsSuccess); + Assert.True(application.Complete(Now.AddMinutes(3)).IsSuccess); + return application; + } + + private static Guid CreateDeterministicGuid(int value) => + Guid.ParseExact( + $"00000000-0000-0000-0000-{value:D12}", + "D"); + + private sealed class TestScopeContext : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => TenantId; + } +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffIdentityAnchorSweepRepositoryTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffIdentityAnchorSweepRepositoryTests.cs new file mode 100644 index 00000000..e9c80ef4 --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffIdentityAnchorSweepRepositoryTests.cs @@ -0,0 +1,508 @@ +namespace BunkFy.Modules.Workspaces.Tests.Persistence; + +using BunkFy.Modules.Workspaces.Application.Models; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Domain.Termination; +using BunkFy.Modules.Workspaces.Persistence; +using BunkFy.Modules.Workspaces.Persistence.Repositories; +using Gma.Framework.Scoping; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class WorkspaceStaffIdentityAnchorSweepRepositoryTests +{ + private const string TenantId = "tenant-a"; + private static readonly DateTimeOffset Now = + new(2026, 8, 11, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void Model_uses_a_store_generated_positive_scoped_unique_ordinal() + { + using WorkspacesDbContext context = CreateContext(); + IEntityType onboarding = context.GetService() + .Model + .FindEntityType(typeof(WorkspaceStaffOnboarding))!; + IProperty ordinal = onboarding.FindProperty( + nameof(WorkspaceStaffOnboarding.IdentityAnchorSweepOrdinal))!; + + Assert.Equal(typeof(long), ordinal.ClrType); + Assert.False(ordinal.IsNullable); + Assert.Equal(ValueGenerated.OnAdd, ordinal.ValueGenerated); + Assert.True(typeof(WorkspaceStaffOnboarding).GetProperty( + nameof(WorkspaceStaffOnboarding.IdentityAnchorSweepOrdinal))! + .SetMethod! + .IsPrivate); + Assert.Contains(onboarding.GetIndexes(), index => + index.IsUnique && + index.Properties.Select(property => property.Name) + .SequenceEqual([ + nameof(WorkspaceStaffOnboarding.ScopeId), + nameof( + WorkspaceStaffOnboarding.IdentityAnchorSweepOrdinal) + ])); + Assert.Contains(onboarding.GetCheckConstraints(), constraint => + constraint.Name == + "CK_staff_onboarding_identity_anchor_sweep_ordinal" && + constraint.Sql == "\"IdentityAnchorSweepOrdinal\" > 0"); + } + + [Fact] + public void Checkpoint_model_names_completed_state_as_a_bounded_cycle() + { + using WorkspacesDbContext context = CreateContext(); + IEntityType checkpoint = context.GetService() + .Model + .FindEntityType( + typeof(WorkspaceStaffIdentityAnchorSweepCheckpoint))!; + + Assert.Contains( + checkpoint.GetCheckConstraints(), + constraint => constraint.Name == + "CK_ws_anchor_sweep_last_cycle"); + Assert.DoesNotContain( + checkpoint.GetCheckConstraints(), + constraint => constraint.Name?.Contains( + "snapshot", + StringComparison.Ordinal) == true); + } + + [Fact] + public void PostgreSql_translates_the_ordinal_keyset_and_ordering() + { + DbContextOptions options = + new DbContextOptionsBuilder() + .UseNpgsql( + "Host=localhost;Database=sweep_translation;" + + "Username=test;Password=test") + .Options; + using WorkspacesDbContext context = new( + options, + new TestScopeContext()); + + string sql = context.StaffOnboardingApplications + .AsNoTracking() + .Where(application => + application.IdentityAnchorSweepOrdinal > 10 && + application.IdentityAnchorSweepOrdinal <= 42) + .OrderBy(application => + application.IdentityAnchorSweepOrdinal) + .Select(application => new + { + application.Id, + application.IdentityAnchorSweepOrdinal + }) + .Take(501) + .ToQueryString(); + + Assert.Contains( + "\"IdentityAnchorSweepOrdinal\" >", + sql, + StringComparison.Ordinal); + Assert.Contains( + "\"IdentityAnchorSweepOrdinal\" <=", + sql, + StringComparison.Ordinal); + Assert.Contains("ORDER BY", sql, StringComparison.Ordinal); + Assert.Contains( + "\"IdentityAnchorSweepOrdinal\"", + sql[sql.IndexOf("ORDER BY", StringComparison.Ordinal)..], + StringComparison.Ordinal); + } + + [Fact] + public async Task Full_universe_is_cyclic_and_fixed_to_the_cycle_upper_bound() + { + await using WorkspacesDbContext context = CreateContext(); + WorkspaceStaffOnboarding first = CreateApplication(Id(10), 1); + WorkspaceStaffOnboarding terminalNullTarget = + CreateApplication(Id(20), 2); + Assert.True(terminalNullTarget.Supersede(Now.AddMinutes(1)).IsSuccess); + WorkspaceStaffOnboarding third = CreateApplication(Id(30), 3); + await PersistApplicationsAsync( + context, + first, + terminalNullTarget, + third); + WorkspaceStaffIdentityAnchorSweepRepository repository = new(context); + Assert.True(first.IdentityAnchorSweepOrdinal > 0); + Assert.True(terminalNullTarget.IdentityAnchorSweepOrdinal > + first.IdentityAnchorSweepOrdinal); + Assert.True(third.IdentityAnchorSweepOrdinal > + terminalNullTarget.IdentityAnchorSweepOrdinal); + + WorkspaceStaffIdentityAnchorSweepPage firstPage = + (await repository.PreparePageAsync( + TenantId, + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + batchSize: 2, + Now.AddMinutes(2), + CancellationToken.None)).Value; + await context.SaveChangesAsync(); + + Assert.Equal(third.IdentityAnchorSweepOrdinal, firstPage.UpperOrdinal); + Assert.Equal([Id(10), Id(20)], + firstPage.Candidates.Select(candidate => candidate.ApplicationId)); + WorkspaceStaffIdentityAnchorSweepCandidate terminalCandidate = + Assert.Single(firstPage.Candidates, + candidate => candidate.ApplicationId == Id(20)); + Assert.False(terminalCandidate.HasLocalAnchorState); + Assert.Null(terminalNullTarget.StaffMemberId); + Assert.False(firstPage.ReachedEnd); + + Assert.True((await repository.AdvanceAsync( + Advance(firstPage, noAnchorCount: 2), + Now.AddMinutes(3), + CancellationToken.None)).IsSuccess); + await context.SaveChangesAsync(); + WorkspaceStaffOnboarding insertedBehindGuid = + CreateApplication(Id(5), 4); + await PersistApplicationsAsync(context, insertedBehindGuid); + Assert.True(insertedBehindGuid.IdentityAnchorSweepOrdinal > + firstPage.UpperOrdinal); + + WorkspaceStaffIdentityAnchorSweepPage secondPage = + (await repository.PreparePageAsync( + TenantId, + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + batchSize: 2, + Now.AddMinutes(4), + CancellationToken.None)).Value; + Assert.Equal([Id(30)], + secondPage.Candidates.Select(candidate => candidate.ApplicationId)); + Assert.DoesNotContain( + Id(5), + secondPage.Candidates.Select(candidate => candidate.ApplicationId)); + Assert.Equal(firstPage.UpperOrdinal, secondPage.UpperOrdinal); + Assert.True(secondPage.ReachedEnd); + + Assert.True((await repository.AdvanceAsync( + Advance(secondPage, noAnchorCount: 1), + Now.AddMinutes(5), + CancellationToken.None)).IsSuccess); + await context.SaveChangesAsync(); + WorkspaceStaffIdentityAnchorSweepStatus completedStatus = + await repository.GetStatusAsync( + TenantId, + CancellationToken.None); + Assert.False(completedStatus.HasActiveCycle); + Assert.Equal(3, completedStatus.LastCompletedCycle.ScannedCount); + Assert.True(completedStatus.HasCompletedCycleObservation); + Assert.True(completedStatus.HasCompletedBoundedCycle); + Assert.Equal( + 0, + completedStatus.LastCompletedObservedBacklogCount); + + WorkspaceStaffIdentityAnchorSweepPage nextCycle = + (await repository.PreparePageAsync( + TenantId, + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + batchSize: 10, + Now.AddMinutes(6), + CancellationToken.None)).Value; + Assert.Equal(insertedBehindGuid.IdentityAnchorSweepOrdinal, + nextCycle.UpperOrdinal); + Assert.Contains( + nextCycle.Candidates, + candidate => candidate.ApplicationId == Id(5)); + } + + [Fact] + public async Task Fixed_upper_completes_while_new_rows_are_continuously_inserted() + { + await using WorkspacesDbContext context = CreateContext(); + WorkspaceStaffOnboarding[] initial = + [ + CreateApplication(Id(900), 900), + CreateApplication(Id(800), 800), + CreateApplication(Id(700), 700) + ]; + await PersistApplicationsAsync(context, initial); + WorkspaceStaffIdentityAnchorSweepRepository repository = new(context); + List processed = []; + List inserted = []; + long? fixedUpper = null; + bool completed = false; + + for (int pageNumber = 0; pageNumber < 6; pageNumber++) + { + WorkspaceStaffIdentityAnchorSweepPage page = + (await repository.PreparePageAsync( + TenantId, + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + batchSize: 1, + Now.AddMinutes(10 + (pageNumber * 3)), + CancellationToken.None)).Value; + await context.SaveChangesAsync(); + fixedUpper ??= page.UpperOrdinal; + Assert.Equal(fixedUpper, page.UpperOrdinal); + processed.Add(Assert.Single(page.Candidates).ApplicationId); + + Assert.True((await repository.AdvanceAsync( + Advance(page, noAnchorCount: 1), + Now.AddMinutes(11 + (pageNumber * 3)), + CancellationToken.None)).IsSuccess); + await context.SaveChangesAsync(); + if (page.ReachedEnd) + { + completed = true; + break; + } + + WorkspaceStaffOnboarding newcomer = CreateApplication( + Id(100 - pageNumber), + 1_100 + pageNumber); + await PersistApplicationsAsync(context, newcomer); + Assert.True(newcomer.IdentityAnchorSweepOrdinal > fixedUpper); + inserted.Add(newcomer); + } + + Assert.True(completed); + Assert.Equal(initial.Select(application => application.Id), processed); + Assert.DoesNotContain( + processed, + applicationId => inserted.Any(application => + application.Id == applicationId)); + WorkspaceStaffIdentityAnchorSweepStatus status = + await repository.GetStatusAsync( + TenantId, + CancellationToken.None); + Assert.Equal(3, status.LastCompletedCycle.ScannedCount); + Assert.Equal(fixedUpper, status.LastCompletedUpperOrdinal); + Assert.True(status.HasCompletedBoundedCycle); + } + + [Fact] + public async Task Concurrent_advance_conflicts_but_exact_committed_replay_succeeds() + { + DbContextOptions options = + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + WorkspaceStaffIdentityAnchorSweepPage seededPage; + await using (WorkspacesDbContext seed = new( + options, + new TestScopeContext())) + { + await PersistApplicationsAsync( + seed, + CreateApplication(Id(501), 501)); + WorkspaceStaffIdentityAnchorSweepRepository repository = + new(seed); + seededPage = (await repository.PreparePageAsync( + TenantId, + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + batchSize: 10, + Now.AddMinutes(30), + CancellationToken.None)).Value; + await seed.SaveChangesAsync(); + } + + WorkspaceStaffIdentityAnchorSweepAdvance committedAdvance = + Advance(seededPage, noAnchorCount: 1); + await using (WorkspacesDbContext first = new( + options, + new TestScopeContext())) + await using (WorkspacesDbContext second = new( + options, + new TestScopeContext())) + { + WorkspaceStaffIdentityAnchorSweepRepository firstRepository = + new(first); + WorkspaceStaffIdentityAnchorSweepRepository secondRepository = + new(second); + Assert.True((await firstRepository.AdvanceAsync( + committedAdvance, + Now.AddMinutes(31), + CancellationToken.None)).IsSuccess); + Assert.True((await secondRepository.AdvanceAsync( + committedAdvance with { AdvanceId = Guid.NewGuid() }, + Now.AddMinutes(31), + CancellationToken.None)).IsSuccess); + + await first.SaveChangesAsync(); + await Assert.ThrowsAsync( + () => second.SaveChangesAsync()); + } + + await using WorkspacesDbContext replay = new( + options, + new TestScopeContext()); + WorkspaceStaffIdentityAnchorSweepRepository replayRepository = + new(replay); + Assert.True((await replayRepository.AdvanceAsync( + committedAdvance, + Now.AddMinutes(32), + CancellationToken.None)).IsSuccess); + await replay.SaveChangesAsync(); + } + + [Fact] + public async Task Scope_stream_is_ordered_cancellable_and_includes_terminal_only_tenants() + { + await using WorkspacesDbContext context = CreateUnscopedContext(); + WorkspaceStaffOnboarding tenantATarget = CreateApplication( + "tenant-a", + Id(101), + 101); + WorkspaceStaffOnboarding tenantBTerminalOnly = CreateApplication( + "tenant-b", + Id(102), + 102); + Assert.True(tenantBTerminalOnly.Supersede( + Now.AddMinutes(1)).IsSuccess); + WorkspaceStaffOnboarding tenantCFenced = CreateApplication( + "tenant-c", + Id(103), + 103); + await PersistApplicationsAsync( + context, + tenantCFenced, + tenantBTerminalOnly, + tenantATarget); + WorkspaceTerminationFence fence = WorkspaceTerminationFence.Freeze( + Guid.NewGuid(), + "tenant-c", + Guid.NewGuid(), + Guid.NewGuid(), + approvalRevision: 1, + Guid.NewGuid(), + new string('a', 64), + "system:test", + Now.AddMinutes(2)).Value; + context.WorkspaceTerminationFences.Add(fence); + await context.SaveChangesAsync(); + WorkspaceStaffIdentityAnchorSweepRepository repository = new(context); + + string[] scopes = await repository + .StreamScheduleScopeIdsAsync(CancellationToken.None) + .ToArrayAsync(CancellationToken.None); + + Assert.Equal(["tenant-a", "tenant-b"], scopes); + Assert.Null(tenantBTerminalOnly.StaffMemberId); + Assert.Equal( + WorkspaceStaffOnboardingState.Superseded, + tenantBTerminalOnly.Status); + + using CancellationTokenSource canceled = new(); + await canceled.CancelAsync(); + await Assert.ThrowsAnyAsync(async () => + await repository + .StreamScheduleScopeIdsAsync(canceled.Token) + .ToArrayAsync(canceled.Token)); + } + + private static WorkspaceStaffIdentityAnchorSweepAdvance Advance( + WorkspaceStaffIdentityAnchorSweepPage page, + long noAnchorCount) => + new( + page.CheckpointId, + page.CheckpointVersion, + page.CycleId, + page.ExpectedAfterOrdinal, + page.NextAfterOrdinal!.Value, + page.ReachedEnd, + Guid.NewGuid(), + Guid.NewGuid(), + new WorkspaceStaffIdentityAnchorSweepPageCounts( + page.Candidates.Count, + noAnchorCount, + RemovedCount: 0, + ObservedCount: 0, + AlreadyObservedCount: 0, + DeferredCount: 0, + ConflictCount: 0, + PassOneCommittedCount: 0, + ResolutionRecordConfirmedCount: 0)); + + private static WorkspacesDbContext CreateContext() => new( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options, + new TestScopeContext()); + + private static WorkspacesDbContext CreateUnscopedContext() => new( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options, + new UnscopedContext()); + + private static async Task PersistApplicationsAsync( + WorkspacesDbContext context, + params WorkspaceStaffOnboarding[] applications) + { + long[] stored = await context.StaffOnboardingApplications + .IgnoreQueryFilters() + .AsNoTracking() + .Select(application => application.IdentityAnchorSweepOrdinal) + .ToArrayAsync(); + long nextOrdinal = stored.DefaultIfEmpty().Max(); + foreach (WorkspaceStaffOnboarding application in applications) + { + Assert.Equal(0, application.IdentityAnchorSweepOrdinal); + context.StaffOnboardingApplications.Add(application); + context.Entry(application) + .Property(candidate => + candidate.IdentityAnchorSweepOrdinal) + .CurrentValue = checked(++nextOrdinal); + } + + await context.SaveChangesAsync(); + } + + private static WorkspaceStaffOnboarding CreateApplication( + Guid id, + int seed) => CreateApplication(TenantId, id, seed); + + private static WorkspaceStaffOnboarding CreateApplication( + string tenantId, + Guid id, + int seed) => + WorkspaceStaffOnboarding.Create( + id, + tenantId, + WorkspaceStaffOnboardingSource.Invitation, + Id(1_000 + seed), + $"subject-{seed}", + $"subject-{seed}@example.test", + $"Subject {seed}", + legalName: null, + workEmail: null, + workPhone: null, + employeeNumber: null, + jobTitle: null, + department: null, + Now).Value; + + private static Guid Id(int value) => + Guid.Parse($"00000000-0000-0000-0000-{value:D12}"); + + private sealed class TestScopeContext : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => TenantId; + } + + private sealed class UnscopedContext : IScopeContext + { + public bool IsEnabled => false; + public string? ScopeId => null; + } +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFenceTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFenceTests.cs new file mode 100644 index 00000000..c7c37680 --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFenceTests.cs @@ -0,0 +1,226 @@ +namespace BunkFy.Modules.Workspaces.Tests; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Persistence; +using BunkFy.Modules.Workspaces.Persistence.Repositories; +using Gma.Framework.Scoping; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class + WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFenceTests +{ + private const string TenantId = + "10000000-0000-0000-0000-000000000001"; + private const string SubjectId = "subject-a"; + private static readonly DateTimeOffset Now = new( + 2026, + 8, + 11, + 12, + 0, + 0, + TimeSpan.Zero); + + [Fact] + public async Task Exact_absent_unanchored_rows_are_read_in_bounded_pages() + { + await using WorkspacesDbContext context = CreateContext(); + for (int index = 1; index <= 501; index++) + { + context.StaffOnboardingApplications.Add( + CreateApplication(CreateOrderedId(index), SubjectId)); + } + + context.StaffOnboardingApplications.Add( + CreateApplication( + CreateOrderedId(502), + "unrelated-subject")); + await context.SaveChangesAsync(); + RecordingOutcomeReader outcomes = new(); + WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence fence = + CreateFence(context, outcomes); + + bool allowed = await fence.CanMutateAsync( + TenantId, + SubjectId, + CancellationToken.None); + + Assert.True(allowed); + Assert.Equal([500, 1], outcomes.BatchSizes); + Assert.All( + outcomes.ExpectedSubjects, + subject => Assert.Equal(SubjectId, subject)); + } + + [Fact] + public async Task Unresolved_anchor_blocks_subject_mutation() + { + await using WorkspacesDbContext context = CreateContext(); + WorkspaceStaffOnboarding application = CreateApplication( + CreateOrderedId(1), + SubjectId); + context.StaffOnboardingApplications.Add(application); + await context.SaveChangesAsync(); + Guid staffMemberId = Guid.Parse( + "20000000-0000-0000-0000-000000000001"); + RecordingOutcomeReader outcomes = new(request => new( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved, + staffMemberId, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact, + WorkspaceApplicationVersion: application.Version, + ResolutionDisposition: null, + ResolutionEventId: Guid.Parse( + "30000000-0000-0000-0000-000000000001"))); + + bool allowed = await CreateFence(context, outcomes).CanMutateAsync( + TenantId, + SubjectId, + CancellationToken.None); + + Assert.False(allowed); + Assert.Equal([1], outcomes.BatchSizes); + } + + [Fact] + public async Task Malformed_result_set_fails_closed() + { + await using WorkspacesDbContext context = CreateContext(); + context.StaffOnboardingApplications.Add( + CreateApplication(CreateOrderedId(1), SubjectId)); + await context.SaveChangesAsync(); + RecordingOutcomeReader outcomes = new(returnEmpty: true); + + bool allowed = await CreateFence(context, outcomes).CanMutateAsync( + TenantId, + SubjectId, + CancellationToken.None); + + Assert.False(allowed); + } + + [Fact] + public async Task Empty_tenant_fails_closed_even_without_a_subject() + { + await using WorkspacesDbContext context = CreateContext(); + RecordingOutcomeReader outcomes = new(); + + bool allowed = await CreateFence(context, outcomes).CanMutateAsync( + " ", + subjectId: null, + CancellationToken.None); + + Assert.False(allowed); + Assert.Empty(outcomes.BatchSizes); + } + + [Fact] + public async Task Different_active_tenant_fails_closed_before_read() + { + await using WorkspacesDbContext context = CreateContext(); + RecordingOutcomeReader outcomes = new(); + + bool allowed = await CreateFence(context, outcomes).CanMutateAsync( + "20000000-0000-0000-0000-000000000002", + SubjectId, + CancellationToken.None); + + Assert.False(allowed); + Assert.Empty(outcomes.BatchSizes); + } + + private static WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence + CreateFence( + WorkspacesDbContext context, + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader outcomes) => + new( + context, + outcomes, + new TestScopeContext(), + NullLogger< + WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence> + .Instance); + + private static WorkspacesDbContext CreateContext() => new( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options, + new TestScopeContext()); + + private static WorkspaceStaffOnboarding CreateApplication( + Guid applicationId, + string subjectId) => WorkspaceStaffOnboarding.Create( + applicationId, + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + Guid.NewGuid(), + subjectId, + $"{subjectId}@example.test", + "Subject", + legalName: null, + workEmail: null, + workPhone: null, + employeeNumber: null, + jobTitle: null, + department: null, + Now).Value; + + private static Guid CreateOrderedId(int value) => Guid.Parse( + $"00000000-0000-0000-0000-{value:D12}"); + + private sealed class RecordingOutcomeReader( + Func< + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest, + StaffWorkspaceOnboardingIdentityAnchorOutcome>? resolve = null, + bool returnEmpty = false) + : IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + { + public List BatchSizes { get; } = []; + public List ExpectedSubjects { get; } = []; + + public Task> ReadAsync( + IReadOnlyList< + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest> + requests, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + this.BatchSizes.Add(requests.Count); + this.ExpectedSubjects.AddRange( + requests.Select(request => request.ExpectedAuthSubjectId)); + if (returnEmpty) + { + return Task.FromResult>([]); + } + + return Task.FromResult>( + requests.Select(request => resolve?.Invoke(request) ?? new( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Absent, + StaffMemberId: null, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Unknown, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Unknown, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + ResolutionEventId: null)) + .ToArray()); + } + } + + private sealed class TestScopeContext : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => TenantId; + } +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffOnboardingRestorationSuppressionReaderTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffOnboardingRestorationSuppressionReaderTests.cs new file mode 100644 index 00000000..07565d73 --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspaceStaffOnboardingRestorationSuppressionReaderTests.cs @@ -0,0 +1,223 @@ +namespace BunkFy.Modules.Workspaces.Tests; + +using System.Reflection; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Persistence; +using BunkFy.Modules.Workspaces.Persistence.Repositories; +using Gma.Framework.Scoping; +using Microsoft.EntityFrameworkCore; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class WorkspaceStaffOnboardingRestorationSuppressionReaderTests +{ + private const string TenantId = "tenant-a"; + private const string SubjectId = "subject-a"; + private static readonly Guid StaffMemberId = Guid.Parse( + "30000000-0000-0000-0000-000000000001"); + private static readonly DateTimeOffset Now = new( + 2026, + 8, + 11, + 12, + 0, + 0, + TimeSpan.Zero); + + [Fact] + public async Task Completed_resolution_does_not_suppress_normal_restoration() + { + await using WorkspacesDbContext context = CreateContext(); + context.StaffOnboardingApplications.Add( + CreateCompletedApplication( + Guid.Parse("10000000-0000-0000-0000-000000000001"), + StaffMemberId, + SubjectId)); + await context.SaveChangesAsync(); + + WorkspaceStaffOnboardingRestorationSuppressionReader reader = new( + context); + WorkspaceStaffOnboardingRestorationSuppressionState result = + await reader.ReadAsync( + TenantId, + StaffMemberId, + SubjectId, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffOnboardingRestorationSuppressionState.None, + result); + } + + [Fact] + public async Task Any_negative_resolution_is_sticky_for_the_exact_member_and_subject() + { + await using WorkspacesDbContext context = CreateContext(); + context.StaffOnboardingApplications.AddRange( + CreateTerminalApplication( + Guid.Parse("10000000-0000-0000-0000-000000000002"), + StaffMemberId, + SubjectId), + CreateCompletedApplication( + Guid.Parse("10000000-0000-0000-0000-000000000003"), + StaffMemberId, + SubjectId)); + await context.SaveChangesAsync(); + + WorkspaceStaffOnboardingRestorationSuppressionReader reader = new( + context); + WorkspaceStaffOnboardingRestorationSuppressionState result = + await reader.ReadAsync( + TenantId, + StaffMemberId, + SubjectId, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffOnboardingRestorationSuppressionState.Suppressed, + result); + } + + [Fact] + public async Task Negative_resolution_does_not_poison_another_subject_or_member() + { + await using WorkspacesDbContext context = CreateContext(); + context.StaffOnboardingApplications.Add( + CreateTerminalApplication( + Guid.Parse("10000000-0000-0000-0000-000000000004"), + StaffMemberId, + SubjectId)); + await context.SaveChangesAsync(); + + WorkspaceStaffOnboardingRestorationSuppressionReader reader = new( + context); + WorkspaceStaffOnboardingRestorationSuppressionState anotherSubject = + await reader.ReadAsync( + TenantId, + StaffMemberId, + "subject-b", + CancellationToken.None); + WorkspaceStaffOnboardingRestorationSuppressionState anotherMember = + await reader.ReadAsync( + TenantId, + Guid.Parse("30000000-0000-0000-0000-000000000002"), + SubjectId, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffOnboardingRestorationSuppressionState.None, + anotherSubject); + Assert.Equal( + WorkspaceStaffOnboardingRestorationSuppressionState.None, + anotherMember); + } + + [Fact] + public async Task Partial_resolution_coordinates_fail_closed() + { + await using WorkspacesDbContext context = CreateContext(); + WorkspaceStaffOnboarding malformed = CreateApplication( + Guid.Parse("10000000-0000-0000-0000-000000000005"), + SubjectId); + SetProperty( + malformed, + nameof(WorkspaceStaffOnboarding.StaffMemberId), + StaffMemberId); + SetProperty( + malformed, + nameof(WorkspaceStaffOnboarding + .IdentityAnchorResolutionStaffMemberId), + StaffMemberId); + context.StaffOnboardingApplications.Add(malformed); + await context.SaveChangesAsync(); + + WorkspaceStaffOnboardingRestorationSuppressionReader reader = new( + context); + WorkspaceStaffOnboardingRestorationSuppressionState result = + await reader.ReadAsync( + TenantId, + StaffMemberId, + SubjectId, + CancellationToken.None); + + Assert.Equal( + WorkspaceStaffOnboardingRestorationSuppressionState.Conflict, + result); + } + + private static WorkspacesDbContext CreateContext() => new( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options, + new TestScopeContext()); + + private static WorkspaceStaffOnboarding CreateCompletedApplication( + Guid applicationId, + Guid staffMemberId, + string subjectId) + { + WorkspaceStaffOnboarding application = CreateApplication( + applicationId, + subjectId); + Assert.True(application.ObserveInvitationAccepted( + Now.AddMinutes(1)).IsSuccess); + Assert.True(application.MarkStaffReady( + staffMemberId, + Guid.NewGuid(), + Guid.NewGuid(), + Now.AddMinutes(2)).IsSuccess); + Assert.True(application.Complete(Now.AddMinutes(3)).IsSuccess); + return application; + } + + private static WorkspaceStaffOnboarding CreateTerminalApplication( + Guid applicationId, + Guid staffMemberId, + string subjectId) + { + WorkspaceStaffOnboarding application = CreateApplication( + applicationId, + subjectId); + Assert.True(application.Supersede(Now.AddMinutes(1)).IsSuccess); + Assert.True(application.ConvergeTerminalStaffAnchor( + staffMemberId, + Guid.NewGuid(), + Now.AddMinutes(2)).IsSuccess); + return application; + } + + private static WorkspaceStaffOnboarding CreateApplication( + Guid applicationId, + string subjectId) => WorkspaceStaffOnboarding.Create( + applicationId, + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + Guid.NewGuid(), + subjectId, + $"{subjectId}@example.test", + "Subject", + legalName: null, + workEmail: null, + workPhone: null, + employeeNumber: null, + jobTitle: null, + department: null, + Now).Value; + + private static void SetProperty( + WorkspaceStaffOnboarding application, + string propertyName, + object value) => typeof(WorkspaceStaffOnboarding) + .GetProperty( + propertyName, + BindingFlags.Instance | BindingFlags.Public | + BindingFlags.NonPublic)! + .SetValue(application, value); + + private sealed class TestScopeContext : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => TenantId; + } +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesDataRightsContributorTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesDataRightsContributorTests.cs index 5557d3b2..31b16595 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesDataRightsContributorTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesDataRightsContributorTests.cs @@ -2,6 +2,7 @@ namespace BunkFy.Modules.Workspaces.Tests; using System.Text.Json; using BunkFy.Modules.DataRights.Contracts; +using BunkFy.Modules.Staff.Contracts; using BunkFy.Modules.Workspaces.Contracts; using BunkFy.Modules.Workspaces.Domain; using BunkFy.Modules.Workspaces.Domain.DataRights; @@ -9,7 +10,10 @@ namespace BunkFy.Modules.Workspaces.Tests; using BunkFy.Modules.Workspaces.Persistence.Repositories; using Gma.Framework.Scoping; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; +using DomainResolutionDisposition = + Workspaces.Domain.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition; [Trait("Category", "Unit")] public sealed class WorkspacesDataRightsContributorTests @@ -85,8 +89,23 @@ await contributor.DiscoverAsync( CancellationToken.None); DataRightsSubjectCandidate activeCandidate = Assert.Single(activeDiscovered.Candidates); - Assert.Equal("a***@example.test", activeCandidate.EmailHint); - Assert.Equal("***0456", activeCandidate.PhoneHint); + Assert.Equal( + "Workspace onboarding: Submitted", + activeCandidate.DisplayName); + Assert.Null(activeCandidate.EmailHint); + Assert.Null(activeCandidate.PhoneHint); + Assert.DoesNotContain( + "Active Staff", + activeCandidate.DisplayName, + StringComparison.Ordinal); + Assert.DoesNotContain( + "active@example.test", + activeCandidate.DisplayName, + StringComparison.Ordinal); + Assert.DoesNotContain( + "active-subject", + activeCandidate.DisplayName, + StringComparison.Ordinal); DataRightsSubjectDiscoveryResult weak = await contributor.DiscoverAsync( @@ -192,7 +211,9 @@ public async Task Export_writes_each_selected_owner_shape_in_deterministic_order SeededGraph graph = SeedGraph(context); await context.SaveChangesAsync(); WorkspacesDataRightsExportContributor contributor = - new(context, new TestScopeContext()); + CreateExportContributor( + context, + OutcomeReaderFor(graph.Onboarding)); CollectingSink onboardingSink = new(); DataRightsSubjectExportResult onboarding = @@ -234,6 +255,60 @@ await contributor.ExportAsync( Field( onboardingSink.Records[0], "workspaces.auth-subject-id").GetString()); + Assert.Equal( + graph.Onboarding.IdentityAnchorExpectedResolutionEventId, + Field( + onboardingSink.Records[0], + "workspaces.identity-anchor.expected-resolution-event-id") + .GetGuid()); + Assert.Equal( + graph.Onboarding.IdentityAnchorContinuationEventId, + Field( + onboardingSink.Records[0], + "workspaces.identity-anchor.continuation-event-id") + .GetGuid()); + Assert.Equal( + graph.Onboarding.IdentityAnchorResolutionEventId, + Field( + onboardingSink.Records[0], + "workspaces.identity-anchor.resolution-event-id") + .GetGuid()); + Assert.Equal( + graph.Onboarding.IdentityAnchorResolutionStaffMemberId, + Field( + onboardingSink.Records[0], + "workspaces.identity-anchor.resolution-staff-member-id") + .GetGuid()); + Assert.Equal( + graph.Onboarding.IdentityAnchorResolutionApplicationVersion, + Field( + onboardingSink.Records[0], + "workspaces.identity-anchor.resolution-application-version") + .GetInt64()); + Assert.Equal( + "completed-redacted", + Field( + onboardingSink.Records[0], + "workspaces.identity-anchor.resolution-disposition") + .GetString()); + Assert.Equal( + graph.Onboarding.IdentityAnchorResolutionIntentAtUtc, + Field( + onboardingSink.Records[0], + "workspaces.identity-anchor.resolution-intent-at-utc") + .GetDateTimeOffset()); + Assert.Equal( + graph.Onboarding.IdentityAnchorResolutionObservedAtUtc, + Field( + onboardingSink.Records[0], + "workspaces.identity-anchor.resolution-observed-at-utc") + .GetDateTimeOffset()); + Assert.Equal( + graph.Onboarding.IdentityAnchorSweepOrdinal, + Field( + onboardingSink.Records[0], + "workspaces.identity-anchor.sweep-ordinal") + .GetInt64()); Assert.Equal( graph.CorrectionReceipt.ExecutionId, Field( @@ -286,6 +361,12 @@ await contributor.ExportAsync( processSink, CancellationToken.None); Assert.Equal(3, process.RecordCount); + Assert.Equal( + "not-applicable", + Field( + processSink.Records[0], + "workspaces.access-process-restoration-disposition") + .GetString()); Assert.Equal( [ WorkspacesDataRightsCoordinates @@ -383,7 +464,9 @@ public async Task Export_rejects_stale_missing_and_cross_scope_coordinates_witho SeededGraph graph = SeedGraph(context); await context.SaveChangesAsync(); WorkspacesDataRightsExportContributor contributor = - new(context, new TestScopeContext()); + CreateExportContributor( + context, + OutcomeReaderFor(graph.Onboarding)); CollectingSink sink = new(); DataRightsSubjectExportResult stale = @@ -425,6 +508,173 @@ await contributor.ExportAsync( Assert.Empty(sink.Records); } + [Fact] + public async Task Onboarding_export_discloses_staged_profile_only_for_exact_absence() + { + await using WorkspacesDbContext context = CreateContext(); + WorkspaceStaffOnboarding onboarding = + WorkspaceStaffOnboarding.Create( + Guid.NewGuid(), + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + Guid.NewGuid(), + SubjectId, + "verified@example.test", + "Ada Operator", + "Ada Example", + "work@example.test", + "+44 20 5555 0100", + "E-42", + "Manager", + "Operations", + Now).Value; + context.StaffOnboardingApplications.Add(onboarding); + await context.SaveChangesAsync(); + WorkspacesDataRightsExportContributor contributor = + CreateExportContributor(context); + CollectingSink sink = new(); + + DataRightsSubjectExportResult result = + await contributor.ExportAsync( + ExportRequest( + WorkspacesDataRightsCoordinates + .StaffOnboardingRecordType, + onboarding.Id, + onboarding.Version), + sink, + CancellationToken.None); + + Assert.Equal(DataRightsSubjectExportStatus.Succeeded, result.Status); + DataRightsExportRecord record = Assert.Single(sink.Records); + Assert.Equal( + "Ada Operator", + Field(record, "workspaces.proposed-display-name").GetString()); + Assert.Equal( + "verified@example.test", + Field(record, "workspaces.verified-account-email").GetString()); + } + + [Fact] + public async Task Onboarding_export_accepts_observed_resolution_after_local_subject_pseudonymisation() + { + await using WorkspacesDbContext context = CreateContext(); + SeededGraph graph = SeedGraph(context); + await context.SaveChangesAsync(); + string pseudonym = "workspaces-erased:subject-pseudonym"; + context.Entry(graph.Onboarding) + .Property(application => application.SubjectId) + .CurrentValue = pseudonym; + context.Entry(graph.Onboarding) + .Property(application => application.Version) + .CurrentValue = graph.Onboarding.Version + 1; + context.Entry(graph.Onboarding) + .Property(application => application.LastChangedAtUtc) + .CurrentValue = Now.AddMinutes(5); + await context.SaveChangesAsync(); + WorkspacesDataRightsExportContributor contributor = + CreateExportContributor( + context, + OutcomeReaderFor( + graph.Onboarding, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Mismatch)); + CollectingSink sink = new(); + + DataRightsSubjectExportResult result = + await contributor.ExportAsync( + ExportRequest( + WorkspacesDataRightsCoordinates + .StaffOnboardingRecordType, + graph.Onboarding.Id, + graph.Onboarding.Version), + sink, + CancellationToken.None); + + Assert.Equal(DataRightsSubjectExportStatus.Succeeded, result.Status); + DataRightsExportRecord record = Assert.Single( + sink.Records, + candidate => candidate.RecordType == + WorkspacesDataRightsCoordinates.StaffOnboardingRecordType); + Assert.Equal( + pseudonym, + Field(record, "workspaces.auth-subject-id").GetString()); + Assert.Equal( + graph.Onboarding.IdentityAnchorResolutionEventId, + Field( + record, + "workspaces.identity-anchor.resolution-event-id").GetGuid()); + } + + [Theory] + [InlineData(StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved)] + [InlineData(StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Corrupt)] + [InlineData(StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved)] + public async Task Onboarding_export_fails_closed_before_writing_for_non_authoritative_anchor_state( + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus status) + { + await using WorkspacesDbContext context = CreateContext(); + WorkspaceStaffOnboarding onboarding = + WorkspaceStaffOnboarding.Create( + Guid.NewGuid(), + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + Guid.NewGuid(), + SubjectId, + "verified@example.test", + "Ada Operator", + legalName: null, + workEmail: null, + workPhone: null, + employeeNumber: null, + jobTitle: null, + department: null, + Now).Value; + context.StaffOnboardingApplications.Add(onboarding); + await context.SaveChangesAsync(); + Guid anchorTarget = Guid.NewGuid(); + Guid resolutionEventId = Guid.NewGuid(); + StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader reader = new( + request => new( + request.ApplicationId, + status, + anchorTarget, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved + ? StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Mismatch + : StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Exact, + status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved + ? 1 + : null, + status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved + ? StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted + : null, + resolutionEventId)); + WorkspacesDataRightsExportContributor contributor = + CreateExportContributor(context, reader); + CollectingSink sink = new(); + + DataRightsSubjectExportResult result = + await contributor.ExportAsync( + ExportRequest( + WorkspacesDataRightsCoordinates + .StaffOnboardingRecordType, + onboarding.Id, + onboarding.Version), + sink, + CancellationToken.None); + + Assert.Equal( + DataRightsSubjectExportStatus.ScopeUnavailable, + result.Status); + Assert.Empty(sink.Records); + } + [Fact] public async Task Onboarding_export_rejects_excess_receipts_before_writing() { @@ -473,7 +723,7 @@ public async Task Onboarding_export_rejects_excess_receipts_before_writing() await context.SaveChangesAsync(); WorkspacesDataRightsExportContributor contributor = - new(context, new TestScopeContext()); + CreateExportContributor(context); CollectingSink sink = new(); DataRightsSubjectExportResult result = @@ -520,10 +770,19 @@ private static SeededGraph SeedGraph( Assert.True( onboarding.MarkStaffReady( StaffMemberId, + Guid.NewGuid(), + Guid.NewGuid(), Now.AddMinutes(2)).IsSuccess); Assert.True( onboarding.Complete( Now.AddMinutes(3)).IsSuccess); + Assert.True( + onboarding.ObserveResolution( + onboarding.IdentityAnchorResolutionEventId!.Value, + StaffMemberId, + onboarding.IdentityAnchorResolutionApplicationVersion!.Value, + onboarding.IdentityAnchorResolutionDisposition!.Value, + Now.AddMinutes(4)).IsSuccess); WorkspaceStaffAccessProcess process = WorkspaceStaffAccessProcess.Create( @@ -747,6 +1006,63 @@ private static WorkspacesDbContext CreateContext() return new(options, new TestScopeContext()); } + private static WorkspacesDataRightsExportContributor + CreateExportContributor( + WorkspacesDbContext context, + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader? reader = + null) => new( + context, + new TestScopeContext(), + new WorkspaceStaffOnboardingSerializedReadBoundary(context), + new WorkspaceStaffOnboardingOperationLock(context), + new WorkspaceStaffOnboardingRepository(context), + reader ?? new StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader(), + NullLogger.Instance); + + private static StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + OutcomeReaderFor( + WorkspaceStaffOnboarding application, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch subjectMatch = + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact) => + new(request => + request.ApplicationId == application.Id && + application.IdentityAnchorResolutionObservedAtUtc.HasValue + ? new StaffWorkspaceOnboardingIdentityAnchorOutcome( + application.Id, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved, + application.StaffMemberId, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + subjectMatch, + application.IdentityAnchorResolutionApplicationVersion, + ToStaffDisposition( + application.IdentityAnchorResolutionDisposition!.Value), + application.IdentityAnchorResolutionEventId) + : StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + .Absent(request)); + + private static StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + ToStaffDisposition(DomainResolutionDisposition disposition) => + disposition switch + { + DomainResolutionDisposition.CompletedRedacted => + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + DomainResolutionDisposition.RejectedRedacted => + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .RejectedRedacted, + DomainResolutionDisposition.SupersededRedacted => + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .SupersededRedacted, + DomainResolutionDisposition.ExpiredRedacted => + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .ExpiredRedacted, + DomainResolutionDisposition.WithdrawnRedacted => + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .WithdrawnRedacted, + _ => throw new InvalidOperationException( + "The test resolution disposition is invalid.") + }; + private sealed class CollectingSink : IDataRightsExportSink { public List Records { get; } = []; diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesModelTests.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesModelTests.cs index a13acb1b..a9fafa9d 100644 --- a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesModelTests.cs +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/Persistence/WorkspacesModelTests.cs @@ -593,7 +593,8 @@ await repository.FindIdBySourceAndSubjectAsync( await repository.ListActionableAsync( new Gma.Framework.Pagination.PageRequest(1, 1), CancellationToken.None); - WorkspaceStaffOnboardingDto first = Assert.Single(firstPage.Items); + WorkspaceStaffOnboardingActionableSummaryDto first = + Assert.Single(firstPage.Items); Assert.Equal(unrestricted.Id, first.ApplicationId); Assert.True(firstPage.HasMore); @@ -601,7 +602,8 @@ await repository.ListActionableAsync( await repository.ListActionableAsync( new Gma.Framework.Pagination.PageRequest(2, 1), CancellationToken.None); - WorkspaceStaffOnboardingDto second = Assert.Single(secondPage.Items); + WorkspaceStaffOnboardingActionableSummaryDto second = + Assert.Single(secondPage.Items); Assert.Equal(unrestrictedSecond.Id, second.ApplicationId); Assert.False(secondPage.HasMore); } 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..824ea644 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 @@ -1,9 +1,13 @@ namespace BunkFy.Modules.Workspaces.Tests; using BunkFy.Modules.DataRights.Contracts; +using BunkFy.Modules.Workspaces.Domain; using BunkFy.Modules.Workspaces.Persistence; using BunkFy.Modules.Workspaces.Persistence.TenantTermination; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage; using Xunit; public sealed partial class WorkspacesTenantTerminationExportContributorTests @@ -31,7 +35,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()); @@ -100,6 +104,206 @@ public void Destroy_progress_rejects_a_batch_above_the_persisted_bound() Assert.Equal(1, operation.CompletedBatchCount); } + [Fact] + public void Destroy_stage_ordinals_preserve_the_existing_protocol_and_append_receipts_after_checkpoints() + { + Assert.Equal( + Enumerable.Range(1, 19), + Enum.GetValues() + .Where(stage => stage is >= + WorkspaceTenantDestroyStage.OutboxMessages and <= + WorkspaceTenantDestroyStage.HistoricalTerminationFences) + .Select(stage => (int)stage)); + Assert.Equal( + 20, + (int)WorkspaceTenantDestroyStage.SweepCheckpoints); + Assert.Equal( + 21, + (int)WorkspaceTenantDestroyStage + .HistoricalNoProvisionReceipts); + Assert.Equal(22, (int)WorkspaceTenantDestroyStage.Completed); + + WorkspaceTenantDestroyOperation operation = Assert.IsType< + WorkspaceTenantDestroyOperation>( + WorkspaceTenantDestroyOperation.TryCreate( + Guid.NewGuid(), + TenantId, + Digest, + Guid.NewGuid(), + selectedFenceVersion: 4, + WorkspaceTenantDestroyOperation.MaximumBatchSize, + Now)); + for (int stage = 1; stage <= 19; stage++) + { + Assert.Equal(stage, (int)operation.Stage); + Assert.True(operation.AdvanceEmptyStage( + Now.AddMinutes(stage))); + } + + Assert.Equal( + WorkspaceTenantDestroyStage.SweepCheckpoints, + operation.Stage); + Assert.True(operation.AdvanceEmptyStage(Now.AddMinutes(20))); + Assert.Equal( + WorkspaceTenantDestroyStage.HistoricalNoProvisionReceipts, + operation.Stage); + Assert.True(operation.AdvanceEmptyStage(Now.AddMinutes(21))); + Assert.Equal(WorkspaceTenantDestroyStage.Completed, operation.Stage); + Assert.False(operation.AdvanceEmptyStage(Now.AddMinutes(22))); + + using WorkspacesDbContext context = CreateContext(); + IEntityType entity = context.GetService().Model + .FindEntityType( + typeof(WorkspaceTenantDestroyOperation))!; + ICheckConstraint constraint = + Assert.Single( + entity.GetCheckConstraints(), + candidate => candidate.Name == + "CK_workspaces_tenant_destroy_operation_progress"); + Assert.Contains( + "\"Stage\" BETWEEN 1 AND 22", + constraint.Sql, + StringComparison.Ordinal); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(1, 2)] + [InlineData(501, 3)] + public async Task Destroy_removes_historical_receipts_in_bounded_batches_and_preserves_the_sentinel( + int receiptCount, + int expectedCompletedBatchCount) + { + const string sentinelTenantId = + "10000000-0000-0000-0000-000000000099"; + string databaseName = Guid.NewGuid().ToString("N"); + InMemoryDatabaseRoot databaseRoot = new(); + await using WorkspacesDbContext tenant = CreateContext( + databaseName, + TenantId, + databaseRoot); + await using WorkspacesDbContext sentinel = CreateContext( + databaseName, + sentinelTenantId, + databaseRoot); + + tenant.StaffHistoricalNoProvisionReceipts.AddRange( + Enumerable.Range(1, receiptCount) + .Select(index => CreateHistoricalReceipt( + index, + TenantId))); + sentinel.StaffHistoricalNoProvisionReceipts.Add( + CreateHistoricalReceipt(10_001, sentinelTenantId)); + await tenant.SaveChangesAsync(); + await sentinel.SaveChangesAsync(); + tenant.ChangeTracker.Clear(); + sentinel.ChangeTracker.Clear(); + + SeedFence(tenant); + await tenant.SaveChangesAsync(); + tenant.ChangeTracker.Clear(); + WorkspaceTenantDestructionOwner owner = new( + tenant, + new TestScopeContext(), + new TestClock()); + + TenantTerminationContributionResult result = + await CompleteDestroyAsync(owner, DestroyRequest()); + + Assert.Equal( + TenantTerminationContributionStatus.Completed, + result.Status); + Assert.Equal(receiptCount + 1, result.AffectedCount); + Assert.Empty(await tenant.StaffHistoricalNoProvisionReceipts + .IgnoreQueryFilters() + .Where(receipt => receipt.ScopeId == TenantId) + .ToListAsync()); + sentinel.ChangeTracker.Clear(); + Assert.Equal( + 10_001, + Assert.Single(await sentinel + .StaffHistoricalNoProvisionReceipts + .IgnoreQueryFilters() + .ToListAsync()).OrganizationsSourceVersion); + WorkspaceTenantDestroyReceipt receipt = + await tenant.TenantDestroyReceipts.SingleAsync(); + Assert.Equal(receiptCount + 1, receipt.RemovedRecordCount); + Assert.Equal( + expectedCompletedBatchCount, + receipt.CompletedBatchCount); + Assert.True(receipt.BatchSize <= + WorkspaceTenantDestroyOperation.MaximumBatchSize); + } + + [Fact] + public async Task Destroy_removes_an_active_sweep_checkpoint_and_preserves_the_sentinel_tenant() + { + const string sentinelTenantId = + "10000000-0000-0000-0000-000000000099"; + string databaseName = Guid.NewGuid().ToString("N"); + InMemoryDatabaseRoot databaseRoot = new(); + await using WorkspacesDbContext tenant = CreateContext( + databaseName, + TenantId, + databaseRoot); + await using WorkspacesDbContext sentinel = CreateContext( + databaseName, + sentinelTenantId, + databaseRoot); + Guid checkpointId = + Guid.Parse("17000000-0000-0000-0000-000000000001"); + Guid sentinelCheckpointId = + Guid.Parse("17000000-0000-0000-0000-000000000099"); + WorkspaceStaffIdentityAnchorSweepCheckpoint active = + WorkspaceStaffIdentityAnchorSweepCheckpoint.Create( + checkpointId, + TenantId, + FrozenAtUtc.AddHours(-3)).Value; + Assert.True(active.BeginCycle( + Guid.Parse("18000000-0000-0000-0000-000000000001"), + upperOrdinal: 7, + Guid.Parse("19000000-0000-0000-0000-000000000001"), + FrozenAtUtc.AddHours(-2)).IsSuccess); + tenant.StaffIdentityAnchorSweepCheckpoints.Add(active); + await tenant.SaveChangesAsync(); + tenant.ChangeTracker.Clear(); + + WorkspaceStaffIdentityAnchorSweepCheckpoint preserved = + WorkspaceStaffIdentityAnchorSweepCheckpoint.Create( + sentinelCheckpointId, + sentinelTenantId, + FrozenAtUtc.AddHours(-3)).Value; + sentinel.StaffIdentityAnchorSweepCheckpoints.Add(preserved); + await sentinel.SaveChangesAsync(); + sentinel.ChangeTracker.Clear(); + + SeedFence(tenant); + await tenant.SaveChangesAsync(); + tenant.ChangeTracker.Clear(); + WorkspaceTenantDestructionOwner owner = new( + tenant, + new TestScopeContext(), + new TestClock()); + + TenantTerminationContributionResult result = + await CompleteDestroyAsync(owner, DestroyRequest()); + + Assert.Equal( + TenantTerminationContributionStatus.Completed, + result.Status); + Assert.Equal(2, result.AffectedCount); + Assert.Empty(await tenant.StaffIdentityAnchorSweepCheckpoints + .ToListAsync()); + sentinel.ChangeTracker.Clear(); + Assert.Equal( + sentinelCheckpointId, + (await sentinel.StaffIdentityAnchorSweepCheckpoints + .SingleAsync()).Id); + WorkspaceTenantDestroyReceipt receipt = + await tenant.TenantDestroyReceipts.SingleAsync(); + Assert.Equal(2, receipt.RemovedRecordCount); + } + private static async Task CompleteDestroyAsync( WorkspaceTenantDestructionOwner owner, @@ -162,6 +366,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 @@ -169,5 +375,9 @@ await context.StaffCorrelationAnonymisationRestoreReceipts await context.StaffCorrelationAnonymisationTombstones .IgnoreQueryFilters().AnyAsync() || await context.StaffCorrelationAnonymisationReceipts + .IgnoreQueryFilters().AnyAsync() || + await context.StaffIdentityAnchorSweepCheckpoints + .IgnoreQueryFilters().AnyAsync() || + await context.StaffHistoricalNoProvisionReceipts .IgnoreQueryFilters().AnyAsync(); } 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..f838cc55 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 @@ -1,6 +1,7 @@ namespace BunkFy.Modules.Workspaces.Tests; using BunkFy.Modules.DataRights.Contracts; +using BunkFy.Modules.Staff.Contracts; using BunkFy.Modules.Workspaces.Contracts; using BunkFy.Modules.Workspaces.Domain; using BunkFy.Modules.Workspaces.Domain.DataRights; @@ -10,6 +11,8 @@ namespace BunkFy.Modules.Workspaces.Tests; using Gma.Framework.Runtime.Time; using Gma.Framework.Scoping; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; [Trait("Category", "Unit")] @@ -42,10 +45,8 @@ public async Task Export_streams_the_complete_portable_workspace_graph_in_stable WorkspaceTerminationFence fence = SeedFence(context); await context.SaveChangesAsync(); context.ChangeTracker.Clear(); - WorkspacesTenantTerminationExportContributor contributor = new( - context, - new TestScopeContext(), - new TestClock()); + WorkspacesTenantTerminationExportContributor contributor = + CreateContributor(context); CollectingSink first = new(); TenantTerminationContributionResult result = @@ -69,6 +70,8 @@ await contributor.ExportAsync( Assert.Equal( [ WorkspacesDataRightsCoordinates.StaffOnboardingRecordType, + WorkspacesDataRightsExportContributor + .StaffDeferredClaimWithdrawalRecordType, WorkspacesDataRightsExportContributor .StaffOnboardingCorrectionReceiptRecordType, WorkspacesDataRightsExportContributor @@ -85,13 +88,56 @@ await contributor.ExportAsync( .StaffRetentionCorrelationReceiptRecordType ], first.Records.Select(record => record.RecordType).ToArray()); - Assert.Equal(9, result.AffectedCount); + Assert.Equal(10, result.AffectedCount); + Assert.DoesNotContain( + first.Records, + record => record.RecordType == + WorkspacesTenantTerminationExportContributor + .StaffIdentityAnchorSweepCheckpointRecordType); + 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); Assert.Equal( WorkspacesTenantTerminationMetadata.CatalogVersion, contributor.ExportDescriptor.CatalogVersion); + Assert.Equal( + WorkspacesTenantTerminationMetadata.ExportSchemaVersion, + contributor.ExportDescriptor.ExportSchemaVersion); + Assert.Contains( + "workspaces.identity-anchor-sweep.structured-control", + contributor.ExportDescriptor.FieldIds); + Assert.DoesNotContain( + "workspaces.identity-anchor-sweep.structured-control", + WorkspacesDataRightsExportSchema.Descriptor.FieldIds); + DataRightsExportRecord accessProcessRecord = Assert.Single( + first.Records, + record => record.RecordType == + WorkspacesDataRightsCoordinates.StaffAccessProcessRecordType); + Assert.Equal( + "restore-snapshot", + Assert.Single( + accessProcessRecord.Fields, + field => field.FieldId == + "workspaces.access-process-restoration-disposition") + .Value.GetString()); CollectingSink replay = new(); TenantTerminationContributionResult replayResult = @@ -106,6 +152,243 @@ await contributor.ExportAsync( replay.Records.Select(Identity).ToArray()); } + [Fact] + public async Task Export_includes_one_privacy_minimal_active_sweep_checkpoint_in_stable_order() + { + await using WorkspacesDbContext context = CreateContext(); + SeedGraph(context); + Guid checkpointId = + Guid.Parse("13000000-0000-0000-0000-000000000001"); + Guid cycleId = + Guid.Parse("14000000-0000-0000-0000-000000000001"); + Guid runId = + Guid.Parse("15000000-0000-0000-0000-000000000001"); + WorkspaceStaffIdentityAnchorSweepCheckpoint checkpoint = + WorkspaceStaffIdentityAnchorSweepCheckpoint.Create( + checkpointId, + TenantId, + FrozenAtUtc.AddHours(-4)).Value; + Assert.True(checkpoint.BeginCycle( + cycleId, + upperOrdinal: 10, + runId, + FrozenAtUtc.AddHours(-3)).IsSuccess); + Assert.True(checkpoint.Advance( + expectedVersion: 2, + cycleId, + expectedAfterOrdinal: null, + nextAfterOrdinal: 4, + reachedEnd: false, + Guid.Parse("16000000-0000-0000-0000-000000000001"), + runId, + new WorkspaceStaffIdentityAnchorSweepPageCounts( + ScannedCount: 4, + NoAnchorCount: 1, + RemovedCount: 0, + ObservedCount: 1, + AlreadyObservedCount: 0, + DeferredCount: 1, + ConflictCount: 1, + PassOneCommittedCount: 2, + ResolutionRecordConfirmedCount: 1), + FrozenAtUtc.AddHours(-2)).IsSuccess); + context.StaffIdentityAnchorSweepCheckpoints.Add(checkpoint); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + WorkspaceTerminationFence fence = SeedFence(context); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + WorkspacesTenantTerminationExportContributor contributor = + CreateContributor(context); + CollectingSink first = new(); + + TenantTerminationContributionResult result = + await contributor.ExportAsync( + Request(fence.Version), + first, + CancellationToken.None); + + Assert.Equal( + TenantTerminationContributionStatus.Completed, + result.Status); + Assert.Equal(11, result.AffectedCount); + Assert.Equal( + [ + WorkspacesDataRightsCoordinates.StaffOnboardingRecordType, + WorkspacesTenantTerminationExportContributor + .StaffIdentityAnchorSweepCheckpointRecordType, + WorkspacesDataRightsExportContributor + .StaffDeferredClaimWithdrawalRecordType + ], + first.Records.Take(3) + .Select(record => record.RecordType) + .ToArray()); + DataRightsExportRecord exported = Assert.Single( + first.Records, + record => record.RecordType == + WorkspacesTenantTerminationExportContributor + .StaffIdentityAnchorSweepCheckpointRecordType); + Assert.Equal(checkpointId, exported.RecordId); + Assert.Equal(checkpoint.Version, exported.RecordVersion); + Assert.Single(exported.Fields); + System.Text.Json.JsonElement state = Field( + exported, + "workspaces.identity-anchor-sweep.structured-control"); + Assert.Equal(1, state.GetProperty("protocolVersion").GetInt32()); + Assert.True(state.GetProperty("hasActiveCycle").GetBoolean()); + Assert.Equal(10, state.GetProperty("cycleUpperOrdinal").GetInt64()); + Assert.Equal(4, state.GetProperty("afterOrdinal").GetInt64()); + Assert.Equal(4, state.GetProperty("cycleScannedCount").GetInt64()); + Assert.Equal(2, state.GetProperty("cycleBacklogCount").GetInt64()); + Assert.Equal( + 0, + state.GetProperty("lastCompletedScannedCount").GetInt64()); + Assert.Equal( + 0, + state.GetProperty("lastCompletedBacklogCount").GetInt64()); + string serialized = state.GetRawText(); + Assert.DoesNotContain("subject", serialized, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("scope", serialized, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("cycleId", serialized, StringComparison.Ordinal); + Assert.DoesNotContain("runId", serialized, StringComparison.Ordinal); + Assert.DoesNotContain("sha256", serialized, StringComparison.OrdinalIgnoreCase); + + CollectingSink replay = new(); + TenantTerminationContributionResult replayResult = + await contributor.ExportAsync( + Request(fence.Version), + replay, + CancellationToken.None); + DataRightsExportRecord replayed = Assert.Single( + replay.Records, + record => record.RecordType == exported.RecordType); + Assert.Equal(result.AffectedCount, replayResult.AffectedCount); + Assert.Equal(Identity(exported), Identity(replayed)); + Assert.Equal( + state.GetRawText(), + Field( + replayed, + "workspaces.identity-anchor-sweep.structured-control") + .GetRawText()); + } + + [Fact] + public async Task Export_includes_privacy_minimal_historical_receipts_only_in_the_tenant_schema() + { + await using WorkspacesDbContext context = CreateContext(); + WorkspaceStaffHistoricalNoProvisionReceipt second = + CreateHistoricalReceipt(2, TenantId); + WorkspaceStaffHistoricalNoProvisionReceipt first = + CreateHistoricalReceipt(1, TenantId); + context.StaffHistoricalNoProvisionReceipts.AddRange(second, first); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + WorkspaceTerminationFence fence = SeedFence(context); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + WorkspacesTenantTerminationExportContributor contributor = + CreateContributor(context); + CollectingSink sink = new(); + + TenantTerminationContributionResult result = + await contributor.ExportAsync( + Request(fence.Version), + sink, + CancellationToken.None); + + Assert.Equal( + TenantTerminationContributionStatus.Completed, + result.Status); + Assert.Equal(2, result.AffectedCount); + Assert.Equal( + [first.Id, second.Id], + sink.Records.Select(record => record.RecordId).ToArray()); + Assert.All(sink.Records, record => + { + Assert.Equal( + WorkspacesTenantTerminationExportContributor + .StaffHistoricalNoProvisionReceiptRecordType, + record.RecordType); + Assert.Equal( + WorkspaceStaffHistoricalNoProvisionReceipt + .CurrentContractVersion, + record.RecordVersion); + }); + + string[] receiptFieldIds = + [ + "workspaces.actor-subject-id", + "workspaces.identity-anchor-historical-no-provision.application-id", + "workspaces.identity-anchor-historical-no-provision.canonical-digest", + "workspaces.identity-anchor-historical-no-provision.contract-version", + "workspaces.identity-anchor-historical-no-provision.expected-application-status", + "workspaces.identity-anchor-historical-no-provision.expected-application-version", + "workspaces.identity-anchor-historical-no-provision.external-evidence-digest", + "workspaces.identity-anchor-historical-no-provision.external-evidence-manifest-id", + "workspaces.identity-anchor-historical-no-provision.operation-id", + "workspaces.identity-anchor-historical-no-provision.organizations-scope-revision", + "workspaces.identity-anchor-historical-no-provision.organizations-source-status", + "workspaces.identity-anchor-historical-no-provision.organizations-source-version", + "workspaces.identity-anchor-historical-no-provision.receipt-id", + "workspaces.identity-anchor-historical-no-provision.result-application-status", + "workspaces.identity-anchor-historical-no-provision.result-application-version", + "workspaces.identity-anchor-historical-no-provision.reviewed-at", + "workspaces.identity-anchor-historical-no-provision.scope-id", + "workspaces.identity-anchor-historical-no-provision.source-id", + "workspaces.identity-anchor-historical-no-provision.source-kind", + "workspaces.identity-anchor-historical-no-provision.staff-evidence-digest" + ]; + DataRightsExportRecord exported = sink.Records[0]; + Assert.Equal( + receiptFieldIds, + exported.Fields.Select(field => field.FieldId).ToArray()); + Assert.Equal( + first.CanonicalSha256, + Field( + exported, + "workspaces.identity-anchor-historical-no-provision.canonical-digest") + .GetString()); + Assert.Equal( + first.ReviewerId, + Field( + exported, + "workspaces.actor-subject-id") + .GetString()); + Assert.All(receiptFieldIds, fieldId => Assert.Contains( + fieldId, + contributor.ExportDescriptor.FieldIds)); + Assert.All( + receiptFieldIds.Where(fieldId => !string.Equals( + fieldId, + "workspaces.actor-subject-id", + StringComparison.Ordinal)), + fieldId => Assert.DoesNotContain( + fieldId, + WorkspacesDataRightsExportSchema.Descriptor.FieldIds)); + string serialized = string.Join( + '|', + exported.Fields.Select(field => field.Value.GetRawText())); + Assert.DoesNotContain( + "email", + serialized, + StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain( + "profile", + serialized, + StringComparison.OrdinalIgnoreCase); + + CollectingSink replay = new(); + TenantTerminationContributionResult replayResult = + await contributor.ExportAsync( + Request(fence.Version), + replay, + CancellationToken.None); + Assert.Equal(result.AffectedCount, replayResult.AffectedCount); + Assert.Equal( + sink.Records.Select(Identity).ToArray(), + replay.Records.Select(Identity).ToArray()); + } + [Fact] public async Task Export_retries_without_writing_when_the_frozen_fence_does_not_match() { @@ -116,10 +399,8 @@ public async Task Export_retries_without_writing_when_the_frozen_fence_does_not_ WorkspaceTerminationFence fence = SeedFence(context); await context.SaveChangesAsync(); context.ChangeTracker.Clear(); - WorkspacesTenantTerminationExportContributor contributor = new( - context, - new TestScopeContext(), - new TestClock()); + WorkspacesTenantTerminationExportContributor contributor = + CreateContributor(context); CollectingSink sink = new(); TenantTerminationContributionResult result = @@ -137,14 +418,264 @@ await contributor.ExportAsync( Assert.Empty(sink.Records); } + [Fact] + public async Task Export_preflights_every_onboarding_page_before_the_first_sink_write() + { + await using WorkspacesDbContext context = CreateContext(); + SeedGraph(context); + for (int index = 1; index <= 501; index++) + { + WorkspaceStaffOnboarding application = + WorkspaceStaffOnboarding.Create( + Coordinate(index), + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + Coordinate(index + 10_000), + $"subject:{index}", + $"staff-{index}@example.test", + $"Staff {index}", + legalName: null, + workEmail: null, + workPhone: null, + employeeNumber: null, + jobTitle: null, + department: null, + FrozenAtUtc.AddDays(-2)).Value; + context.StaffOnboardingApplications.Add(application); + } + + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + WorkspaceTerminationFence fence = SeedFence(context); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + SecondPageUnavailableOutcomeReader outcomes = new(); + WorkspacesTenantTerminationExportContributor contributor = + CreateContributor(context, outcomes); + CollectingSink sink = new(); + + TenantTerminationContributionResult result = + await contributor.ExportAsync( + Request(fence.Version), + sink, + CancellationToken.None); + + Assert.Equal( + TenantTerminationContributionStatus.RetryRequired, + result.Status); + Assert.Equal( + "workspace.termination.export-identity-anchor-unavailable", + result.ResultCode); + Assert.Equal([500, 2], outcomes.BatchSizes); + Assert.Empty(sink.Records); + } + + [Theory] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved)] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Corrupt)] + [InlineData( + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved)] + public async Task Export_preflight_retries_without_writing_for_non_authoritative_anchor_state( + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus status) + { + await using WorkspacesDbContext context = CreateContext(); + SeedGraph(context); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + WorkspaceTerminationFence fence = SeedFence(context); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + Guid target = + Guid.Parse("76000000-0000-0000-0000-000000000001"); + Guid resolutionEventId = + Guid.Parse("77000000-0000-0000-0000-000000000001"); + StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader outcomes = new( + request => new StaffWorkspaceOnboardingIdentityAnchorOutcome( + request.ApplicationId, + status, + target, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved + ? StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Mismatch + : StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Exact, + status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved + ? 1 + : null, + status == + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved + ? StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted + : null, + resolutionEventId)); + WorkspacesTenantTerminationExportContributor contributor = + CreateContributor(context, outcomes); + CollectingSink sink = new(); + + TenantTerminationContributionResult result = + await contributor.ExportAsync( + Request(fence.Version), + sink, + CancellationToken.None); + + Assert.Equal( + TenantTerminationContributionStatus.RetryRequired, + result.Status); + Assert.Equal( + "workspace.termination.export-identity-anchor-unavailable", + result.ResultCode); + Assert.Empty(sink.Records); + } + + [Fact] + public async Task Export_preflight_accepts_exact_observed_resolution_and_exports_its_receipt() + { + await using WorkspacesDbContext context = CreateContext(); + SeedGraph(context); + WorkspaceStaffOnboarding onboarding = Assert.Single( + context.ChangeTracker.Entries()) + .Entity; + Guid staffMemberId = + Guid.Parse("73000000-0000-0000-0000-000000000001"); + Guid resolutionEventId = + Guid.Parse("74000000-0000-0000-0000-000000000001"); + Guid continuationEventId = + Guid.Parse("75000000-0000-0000-0000-000000000001"); + Assert.True( + onboarding.ObserveInvitationAccepted( + FrozenAtUtc.AddHours(-23)).IsSuccess); + Assert.True( + onboarding.MarkStaffReady( + staffMemberId, + resolutionEventId, + continuationEventId, + FrozenAtUtc.AddHours(-22)).IsSuccess); + Assert.True( + onboarding.Complete( + FrozenAtUtc.AddHours(-21)).IsSuccess); + Assert.True( + onboarding.ObserveResolution( + resolutionEventId, + staffMemberId, + onboarding.IdentityAnchorResolutionApplicationVersion!.Value, + onboarding.IdentityAnchorResolutionDisposition!.Value, + FrozenAtUtc.AddHours(-20)).IsSuccess); + string pseudonym = "workspaces-erased:tenant-export-subject"; + context.Entry(onboarding) + .Property(application => application.SubjectId) + .CurrentValue = pseudonym; + context.Entry(onboarding) + .Property(application => application.Version) + .CurrentValue = onboarding.Version + 1; + context.Entry(onboarding) + .Property(application => application.LastChangedAtUtc) + .CurrentValue = FrozenAtUtc.AddHours(-19); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + WorkspaceTerminationFence fence = SeedFence(context); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader reader = new( + request => request.ApplicationId == onboarding.Id + ? new StaffWorkspaceOnboardingIdentityAnchorOutcome( + onboarding.Id, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved, + staffMemberId, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Mismatch, + onboarding.IdentityAnchorResolutionApplicationVersion, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + resolutionEventId) + : StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + .Absent(request)); + WorkspacesTenantTerminationExportContributor contributor = + CreateContributor(context, reader); + CollectingSink sink = new(); + + TenantTerminationContributionResult result = + await contributor.ExportAsync( + Request(fence.Version), + sink, + CancellationToken.None); + + Assert.Equal( + TenantTerminationContributionStatus.Completed, + result.Status); + DataRightsExportRecord exported = Assert.Single( + sink.Records, + record => record.RecordType == + WorkspacesDataRightsCoordinates.StaffOnboardingRecordType); + Assert.Equal( + resolutionEventId, + Field( + exported, + "workspaces.identity-anchor.expected-resolution-event-id") + .GetGuid()); + Assert.Equal( + pseudonym, + Field(exported, "workspaces.auth-subject-id").GetString()); + Assert.Equal( + continuationEventId, + Field( + exported, + "workspaces.identity-anchor.continuation-event-id") + .GetGuid()); + Assert.Equal( + resolutionEventId, + Field( + exported, + "workspaces.identity-anchor.resolution-event-id") + .GetGuid()); + Assert.Equal( + staffMemberId, + Field( + exported, + "workspaces.identity-anchor.resolution-staff-member-id") + .GetGuid()); + Assert.Equal( + onboarding.IdentityAnchorResolutionApplicationVersion, + Field( + exported, + "workspaces.identity-anchor.resolution-application-version") + .GetInt64()); + Assert.Equal( + "completed-redacted", + Field( + exported, + "workspaces.identity-anchor.resolution-disposition") + .GetString()); + Assert.Equal( + onboarding.IdentityAnchorResolutionIntentAtUtc, + Field( + exported, + "workspaces.identity-anchor.resolution-intent-at-utc") + .GetDateTimeOffset()); + Assert.Equal( + onboarding.IdentityAnchorResolutionObservedAtUtc, + Field( + exported, + "workspaces.identity-anchor.resolution-observed-at-utc") + .GetDateTimeOffset()); + Assert.Equal( + onboarding.IdentityAnchorSweepOrdinal, + Field( + exported, + "workspaces.identity-anchor.sweep-ordinal") + .GetInt64()); + } + [Fact] public async Task Export_rejects_non_export_and_cross_tenant_requests() { await using WorkspacesDbContext context = CreateContext(); - WorkspacesTenantTerminationExportContributor contributor = new( - context, - new TestScopeContext(), - new TestClock()); + WorkspacesTenantTerminationExportContributor contributor = + CreateContributor(context); CollectingSink sink = new(); TenantTerminationExportRequest request = Request(1); @@ -293,9 +824,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, @@ -348,15 +889,78 @@ private static TenantTerminationExportRequest Request( private static string Identity(DataRightsExportRecord record) => $"{record.RecordType}|{record.RecordId:N}|{record.RecordVersion}"; + private static System.Text.Json.JsonElement Field( + DataRightsExportRecord record, + string fieldId) => + Assert.Single( + record.Fields, + field => field.FieldId == fieldId).Value; + + private static Guid Coordinate(int value) + { + Span bytes = stackalloc byte[16]; + BitConverter.TryWriteBytes(bytes, value); + bytes[15] = 1; + return new Guid(bytes); + } + + private static WorkspaceStaffHistoricalNoProvisionReceipt + CreateHistoricalReceipt( + int coordinate, + string tenantId) => + WorkspaceStaffHistoricalNoProvisionReceipt.Create( + Coordinate(100_000 + coordinate), + tenantId, + Coordinate(200_000 + coordinate), + Coordinate(300_000 + coordinate), + WorkspaceStaffOnboardingSource.Invitation, + Coordinate(400_000 + coordinate), + expectedApplicationVersion: 3, + WorkspaceStaffOnboardingState.Superseded, + resultApplicationVersion: 3, + WorkspaceStaffOnboardingState.Superseded, + organizationsScopeRevision: coordinate, + organizationsSourceVersion: coordinate, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationRevoked, + new string('a', 64), + Coordinate(500_000 + coordinate), + new string('b', 64), + "operator:historical-review", + FrozenAtUtc.AddSeconds(coordinate)).Value; + private static WorkspacesDbContext CreateContext() + { + InMemoryDatabaseRoot databaseRoot = new(); + return CreateContext( + Guid.NewGuid().ToString("N"), + TenantId, + databaseRoot); + } + + private static WorkspacesDbContext CreateContext( + string databaseName, + string tenantId, + InMemoryDatabaseRoot databaseRoot) { DbContextOptions options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .UseInMemoryDatabase(databaseName, databaseRoot) .Options; - return new(options, new TestScopeContext()); + return new(options, new TestScopeContext(tenantId)); } + private static WorkspacesTenantTerminationExportContributor + CreateContributor( + WorkspacesDbContext context, + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader? reader = + null) => new( + context, + new TestScopeContext(), + new TestClock(), + reader ?? new StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader(), + NullLogger.Instance); + private sealed class CollectingSink : IDataRightsExportSink { public List Records { get; } = []; @@ -371,10 +975,50 @@ public ValueTask WriteAsync( } } - private sealed class TestScopeContext : IScopeContext + private sealed class SecondPageUnavailableOutcomeReader + : IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + { + public List BatchSizes { get; } = []; + + public Task> ReadAsync( + IReadOnlyList< + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest> + requests, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + this.BatchSizes.Add(requests.Count); + int page = this.BatchSizes.Count; + StaffWorkspaceOnboardingIdentityAnchorOutcome[] results = requests + .Select((request, index) => page == 2 && index == 0 + ? new StaffWorkspaceOnboardingIdentityAnchorOutcome( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Unresolved, + Guid.Parse( + "71000000-0000-0000-0000-000000000001"), + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Exact, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + Guid.Parse( + "72000000-0000-0000-0000-000000000001")) + : StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + .Absent(request)) + .ToArray(); + return Task.FromResult>(results); + } + } + + private sealed class TestScopeContext(string scopeId = TenantId) + : IScopeContext { public bool IsEnabled => true; - public string ScopeId => TenantId; + public string ScopeId => scopeId; } private sealed class TestClock : ISystemClock diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/RecordingWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/RecordingWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence.cs new file mode 100644 index 00000000..84398b02 --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/RecordingWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence.cs @@ -0,0 +1,27 @@ +namespace BunkFy.Modules.Workspaces.Tests; + +using BunkFy.Modules.Workspaces.Application.Ports; + +internal sealed class + RecordingWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence( + bool allowed = true, + List? calls = null) + : IWorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence +{ + public int CallCount { get; private set; } + public string? TenantId { get; private set; } + public string? SubjectId { get; private set; } + + public Task CanMutateAsync( + string tenantId, + string? subjectId, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + this.CallCount++; + this.TenantId = tenantId; + this.SubjectId = subjectId; + calls?.Add("identity-anchor-fence"); + return Task.FromResult(allowed); + } +} diff --git a/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader.cs b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader.cs new file mode 100644 index 00000000..79d6e91b --- /dev/null +++ b/src/Modules/Workspaces/tests/BunkFy.Modules.Workspaces.Tests/StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader.cs @@ -0,0 +1,45 @@ +namespace BunkFy.Modules.Workspaces.Tests; + +using BunkFy.Modules.Staff.Contracts; + +internal sealed class StubStaffWorkspaceOnboardingIdentityAnchorOutcomeReader( + Func< + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest, + StaffWorkspaceOnboardingIdentityAnchorOutcome>? resolve = null) + : IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader +{ + public Task> + ReadAsync( + IReadOnlyList< + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest> requests, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (requests.Count is < 1 or > + StaffWorkspaceOnboardingIdentityAnchorLifecycleLimits + .MaximumBatchSize || + requests.Select(request => request.ApplicationId).Distinct().Count() != + requests.Count) + { + throw new ArgumentException( + "The Staff identity-anchor outcome request is invalid.", + nameof(requests)); + } + + StaffWorkspaceOnboardingIdentityAnchorOutcome[] outcomes = requests + .Select(request => resolve?.Invoke(request) ?? Absent(request)) + .ToArray(); + return Task.FromResult>(outcomes); + } + + public static StaffWorkspaceOnboardingIdentityAnchorOutcome Absent( + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest request) => new( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Absent, + StaffMemberId: null, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Unknown, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Unknown, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null); +} diff --git a/src/Shared/BunkFy.AppHost.Composition/BunkFyBackendComposition.cs b/src/Shared/BunkFy.AppHost.Composition/BunkFyBackendComposition.cs index 125f2483..e09e099d 100644 --- a/src/Shared/BunkFy.AppHost.Composition/BunkFyBackendComposition.cs +++ b/src/Shared/BunkFy.AppHost.Composition/BunkFyBackendComposition.cs @@ -185,7 +185,10 @@ public static BunkFyBackendResources AddBunkFyBackend( .WithEnvironment("Tasks__Worker__WorkerGroups__4", "ingestion-maintenance") .WithEnvironment("Tasks__Worker__WorkerGroups__5", "data-rights-workers") .WithEnvironment("Tasks__Worker__WorkerGroups__6", "retention-workers") - .WithEnvironment("Tasks__Worker__WorkerGroups__7", "tenant-termination-workers"); + .WithEnvironment("Tasks__Worker__WorkerGroups__7", "tenant-termination-workers") + .WithEnvironment( + "Tasks__Worker__WorkerGroups__8", + "workspaces-maintenance-workers"); if (sqlServer is { } configuredSqlServer) { worker.WithReference(configuredSqlServer).WaitFor(configuredSqlServer); diff --git a/tests/Architecture.Tests/Hosts/HostCompositionGuardTests.cs b/tests/Architecture.Tests/Hosts/HostCompositionGuardTests.cs index 1cb8c95c..6142c0e0 100644 --- a/tests/Architecture.Tests/Hosts/HostCompositionGuardTests.cs +++ b/tests/Architecture.Tests/Hosts/HostCompositionGuardTests.cs @@ -1037,6 +1037,8 @@ public void Aspire_apphost_wires_infrastructure_and_optional_worker_surfaces() "retention-workers", "Tasks__Worker__WorkerGroups__7", "tenant-termination-workers", + "Tasks__Worker__WorkerGroups__8", + "workspaces-maintenance-workers", "AppHost:AdminApi:Enabled", "AppHost:Worker:Enabled", "AppHost:Redis:Enabled" @@ -1066,6 +1068,10 @@ public void Aspire_apphost_wires_infrastructure_and_optional_worker_surfaces() "tenant-termination-workers", workerGroups, StringComparer.Ordinal); + Assert.Contains( + "workspaces-maintenance-workers", + workerGroups, + StringComparer.Ordinal); } [Fact] 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/Retention/RetentionControlPlaneIntegrationTests.cs b/tests/Integration.Tests/Retention/RetentionControlPlaneIntegrationTests.cs index 897570dc..3467dc11 100644 --- a/tests/Integration.Tests/Retention/RetentionControlPlaneIntegrationTests.cs +++ b/tests/Integration.Tests/Retention/RetentionControlPlaneIntegrationTests.cs @@ -669,6 +669,8 @@ private static async Task Assert.True( onboarding.MarkStaffReady( member.Id, + Guid.NewGuid(), + Guid.NewGuid(), nowUtc.AddSeconds(2)).IsSuccess); Assert.True( onboarding.Complete( diff --git a/tests/Integration.Tests/Staff/StaffIdentityProvisioningAnchorMigrationConcurrencyTests.cs b/tests/Integration.Tests/Staff/StaffIdentityProvisioningAnchorMigrationConcurrencyTests.cs new file mode 100644 index 00000000..b7535fa8 --- /dev/null +++ b/tests/Integration.Tests/Staff/StaffIdentityProvisioningAnchorMigrationConcurrencyTests.cs @@ -0,0 +1,1286 @@ +namespace Integration.Tests.Staff; + +using BunkFy.Modules.Staff.Application.Ports; +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Staff.Domain.Aggregates; +using BunkFy.Modules.Staff.Persistence; +using BunkFy.Modules.Staff.Persistence.Repositories; +using Gma.Framework.Scoping; +using Integration.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class StaffIdentityProvisioningAnchorMigrationConcurrencyTests +{ + private const string PreviousMigration = + "20260811045655_AddStaffOnboardingProvisioningOperations"; + private const string CurrentMigration = + "20260811110753_AddStaffIdentityProvisioningAnchors"; + private const string TenantId = + "10000000-0000-0000-0000-000000000001"; + private const string OtherTenantId = + "10000000-0000-0000-0000-000000000002"; + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Upgrade_fails_fast_for_legacy_writers_then_installs_exact_commit_time_protocol() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_staff_anchor_up_race_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + string connectionString = postgreSql.GetConnectionString(); + Guid staffMemberId = + Guid.Parse("21000000-0000-0000-0000-000000000001"); + Guid otherStaffMemberId = + Guid.Parse("21000000-0000-0000-0000-000000000002"); + Guid legacySourceId = + Guid.Parse("31000000-0000-0000-0000-000000000001"); + Guid otherSourceId = + Guid.Parse("31000000-0000-0000-0000-000000000003"); + Guid resumeSourceId = + Guid.Parse("31000000-0000-0000-0000-000000000005"); + Guid resumeResolutionEventId = + Guid.Parse("51000000-0000-0000-0000-000000000005"); + Guid legacyCreatedStaffMemberId = + Guid.Parse("21000000-0000-0000-0000-000000000005"); + Guid legacyCreatedSourceId = + Guid.Parse("31000000-0000-0000-0000-000000000006"); + DateTimeOffset completedAtUtc = + new(2026, 8, 11, 12, 1, 0, TimeSpan.Zero); + + await using (StaffDbContext setup = CreateDbContext( + connectionString, + "bunkfy-anchor-up-setup")) + { + await setup.Database.GetService() + .MigrateAsync(PreviousMigration).ConfigureAwait(false); + await SeedMemberAsync( + setup, + staffMemberId, + "subject:legacy-anchor-target", + "Legacy Anchor Target").ConfigureAwait(false); + await SeedMemberAsync( + setup, + otherStaffMemberId, + "subject:other-anchor-target", + "Other Anchor Target").ConfigureAwait(false); + await setup.SaveChangesAsync().ConfigureAwait(false); + } + await ExecuteSqlAsync( + connectionString, + """ + UPDATE staff.staff_members + SET "Status" = 2, + "SuspendedAtUtc" = '2026-08-11T11:58:00Z', + "Version" = "Version" + 1 + WHERE "ScopeId" = @scopeId + AND "Id" = @staffMemberId; + """, + ("scopeId", TenantId), + ("staffMemberId", otherStaffMemberId)).ConfigureAwait(false); + + await using NpgsqlConnection legacyResume = new( + WithApplicationName( + connectionString, + "bunkfy-anchor-legacy-resume")); + await legacyResume.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction legacyResumeTransaction = + await legacyResume.BeginTransactionAsync().ConfigureAwait(false); + await LockLegacyLifecycleCoordinatesAsync( + legacyResume, + legacyResumeTransaction, + otherStaffMemberId).ConfigureAwait(false); + Guid oldResumeMutationId = + Guid.Parse("41000000-0000-0000-0000-000000000002"); + await InsertMutationOperationAsync( + legacyResume, + legacyResumeTransaction, + oldResumeMutationId, + otherStaffMemberId, + kind: 4, + fingerprintCharacter: 'e', + completedAtUtc).ConfigureAwait(false); + await AssertUpgradeLockFailureAsync( + connectionString, + "bunkfy-anchor-up-active-resume").ConfigureAwait(false); + await AssertAnchorMigrationNotAppliedAsync(connectionString) + .ConfigureAwait(false); + + await InsertLegacyOutboxAsync( + legacyResume, + legacyResumeTransaction, + oldResumeMutationId, + "LegacyResumeCompleted").ConfigureAwait(false); + await ExecuteNonQueryAsync( + legacyResume, + legacyResumeTransaction, + """ + UPDATE staff.staff_members + SET "Status" = 1, + "SuspendedAtUtc" = NULL, + "Version" = "Version" + 1 + WHERE "ScopeId" = @scopeId + AND "Id" = @staffMemberId; + """, + ("scopeId", TenantId), + ("staffMemberId", otherStaffMemberId)).ConfigureAwait(false); + await AdvanceTenantRevisionAsync( + legacyResume, + legacyResumeTransaction).ConfigureAwait(false); + await legacyResumeTransaction.CommitAsync().ConfigureAwait(false); + + await using NpgsqlConnection legacyWriter = new( + WithApplicationName( + connectionString, + "bunkfy-anchor-legacy-new-member-writer")); + await legacyWriter.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction legacyWriterTransaction = + await legacyWriter.BeginTransactionAsync().ConfigureAwait(false); + await InsertLegacyStaffMemberAsync( + legacyWriter, + legacyWriterTransaction, + legacyCreatedStaffMemberId).ConfigureAwait(false); + await AssertUpgradeLockFailureAsync( + connectionString, + "bunkfy-anchor-up-active-onboarding").ConfigureAwait(false); + await AssertAnchorMigrationNotAppliedAsync(connectionString) + .ConfigureAwait(false); + await InsertMutationOperationAsync( + legacyWriter, + legacyWriterTransaction, + legacyCreatedSourceId, + legacyCreatedStaffMemberId, + kind: 8, + fingerprintCharacter: 'c', + completedAtUtc).ConfigureAwait(false); + await InsertMutationOperationAsync( + legacyWriter, + legacyWriterTransaction, + legacySourceId, + staffMemberId, + kind: 8, + fingerprintCharacter: 'a', + completedAtUtc).ConfigureAwait(false); + await InsertLegacyOutboxAsync( + legacyWriter, + legacyWriterTransaction, + legacyCreatedStaffMemberId, + "LegacyStaffMemberCreated").ConfigureAwait(false); + await AdvanceTenantRevisionAsync( + legacyWriter, + legacyWriterTransaction).ConfigureAwait(false); + + await legacyWriterTransaction.CommitAsync().ConfigureAwait(false); + await using NpgsqlConnection legacyLifecycle = new( + WithApplicationName( + connectionString, + "bunkfy-anchor-legacy-lifecycle")); + await legacyLifecycle.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction legacyLifecycleTransaction = + await legacyLifecycle.BeginTransactionAsync().ConfigureAwait(false); + await LockLegacyLifecycleCoordinatesAsync( + legacyLifecycle, + legacyLifecycleTransaction, + staffMemberId).ConfigureAwait(false); + await using (StaffDbContext upgrade = CreateDbContext( + connectionString, + "bunkfy-anchor-up-after-drain")) + { + await upgrade.Database.GetService() + .MigrateAsync().ConfigureAwait(false); + } + + Guid resolutionEventId = await AssertExactAnchorAsync( + connectionString, + legacySourceId, + staffMemberId, + completedAtUtc).ConfigureAwait(false); + Assert.Equal( + 1L, + await CountMutationOperationAsync( + connectionString, + oldResumeMutationId).ConfigureAwait(false)); + _ = await AssertExactAnchorAsync( + connectionString, + legacyCreatedSourceId, + legacyCreatedStaffMemberId, + completedAtUtc).ConfigureAwait(false); + + await InsertKind8AndExactAnchorAsync( + connectionString, + resumeSourceId, + otherStaffMemberId, + resumeResolutionEventId, + completedAtUtc.AddMinutes(1)).ConfigureAwait(false); + Assert.Equal( + 1L, + await CountMutationOperationAsync( + connectionString, + resumeSourceId).ConfigureAwait(false)); + Assert.Equal( + resumeResolutionEventId, + await AssertExactAnchorAsync( + connectionString, + resumeSourceId, + otherStaffMemberId, + completedAtUtc.AddMinutes(1)).ConfigureAwait(false)); + + await ExecuteNonQueryAsync( + legacyLifecycle, + legacyLifecycleTransaction, + "SAVEPOINT before_resume_guard;").ConfigureAwait(false); + await ExecuteNonQueryAsync( + legacyLifecycle, + legacyLifecycleTransaction, + """ + UPDATE staff.staff_members + SET "Status" = 2, + "SuspendedAtUtc" = '2026-08-11T12:02:00Z', + "Version" = "Version" + 1 + WHERE "ScopeId" = @scopeId + AND "Id" = @staffMemberId; + """, + ("scopeId", TenantId), + ("staffMemberId", staffMemberId)).ConfigureAwait(false); + await ExecuteNonQueryAsync( + legacyLifecycle, + legacyLifecycleTransaction, + "SAVEPOINT before_resume_attempt;").ConfigureAwait(false); + PostgresException oldResumeFailure = await Assert.ThrowsAsync< + PostgresException>(() => ExecuteNonQueryAsync( + legacyLifecycle, + legacyLifecycleTransaction, + """ + UPDATE staff.staff_members + SET "Status" = 1, + "SuspendedAtUtc" = NULL, + "Version" = "Version" + 1 + WHERE "ScopeId" = @scopeId + AND "Id" = @staffMemberId; + """, + ("scopeId", TenantId), + ("staffMemberId", staffMemberId))); + Assert.Equal("P0001", oldResumeFailure.SqlState); + Assert.Contains( + "Unresolved Workspace onboarding anchor blocks Staff identity lifecycle mutation", + oldResumeFailure.MessageText, + StringComparison.Ordinal); + await ExecuteNonQueryAsync( + legacyLifecycle, + legacyLifecycleTransaction, + "ROLLBACK TO SAVEPOINT before_resume_attempt;") + .ConfigureAwait(false); + await ExecuteNonQueryAsync( + legacyLifecycle, + legacyLifecycleTransaction, + "ROLLBACK TO SAVEPOINT before_resume_guard;") + .ConfigureAwait(false); + + Guid oldAuthMutationId = + Guid.Parse("41000000-0000-0000-0000-000000000001"); + await InsertMutationOperationAsync( + legacyLifecycle, + legacyLifecycleTransaction, + oldAuthMutationId, + staffMemberId, + kind: 2, + fingerprintCharacter: 'b', + completedAtUtc.AddMinutes(1)).ConfigureAwait(false); + PostgresException oldWriterFailure = await Assert.ThrowsAsync< + PostgresException>(async () => + await ExecuteNonQueryAsync( + legacyLifecycle, + legacyLifecycleTransaction, + """ + UPDATE staff.staff_members + SET "AuthSubjectId" = 'subject:legacy-mutated', + "Version" = "Version" + 1 + WHERE "ScopeId" = @scopeId + AND "Id" = @staffMemberId; + """, + ("scopeId", TenantId), + ("staffMemberId", staffMemberId)).ConfigureAwait(false)); + Assert.Equal("P0001", oldWriterFailure.SqlState); + Assert.Contains( + "Workspace onboarding anchor requires access closure before Staff Auth subject change", + oldWriterFailure.MessageText, + StringComparison.Ordinal); + await legacyLifecycleTransaction.RollbackAsync() + .ConfigureAwait(false); + Assert.Equal( + 0L, + await CountMutationOperationAsync( + connectionString, + oldAuthMutationId).ConfigureAwait(false)); + Assert.Equal( + "subject:legacy-anchor-target", + await ReadAuthSubjectAsync( + connectionString, + staffMemberId).ConfigureAwait(false)); + + PostgresException statusFailure = await Assert.ThrowsAsync< + PostgresException>(() => ExecuteSqlAsync( + connectionString, + """ + UPDATE staff.staff_members + SET "AuthSubjectId" = NULL, + "Status" = 4, + "SuspendedAtUtc" = NULL, + "DepartedAtUtc" = '2026-08-11T12:02:00Z', + "DepartureEffectiveOn" = DATE '2026-08-11', + "AnonymisedAtUtc" = '2026-08-11T12:03:00Z', + "Version" = "Version" + 1 + WHERE "ScopeId" = @scopeId + AND "Id" = @staffMemberId; + """, + ("scopeId", TenantId), + ("staffMemberId", staffMemberId))); + Assert.Equal("P0001", statusFailure.SqlState); + Assert.Contains( + "Unresolved Workspace onboarding anchor blocks Staff identity lifecycle mutation", + statusFailure.MessageText, + StringComparison.Ordinal); + + Guid unanchoredSourceId = + Guid.Parse("31000000-0000-0000-0000-000000000002"); + PostgresException missingAnchorFailure = + await InsertKind8AndCommitExpectFailureAsync( + connectionString, + unanchoredSourceId, + otherStaffMemberId).ConfigureAwait(false); + Assert.Equal("P0001", missingAnchorFailure.SqlState); + Assert.Contains( + "Workspace onboarding receipt requires an exact Staff identity provisioning anchor", + missingAnchorFailure.MessageText, + StringComparison.Ordinal); + + PostgresException unresolvedDeleteFailure = + await DeleteKind8AndCommitExpectFailureAsync( + connectionString, + legacySourceId, + staffMemberId).ConfigureAwait(false); + Assert.Equal("P0001", unresolvedDeleteFailure.SqlState); + Assert.Contains( + "Unresolved Workspace onboarding receipt cannot be removed", + unresolvedDeleteFailure.MessageText, + StringComparison.Ordinal); + + PostgresException wrongEventFailure = + await Assert.ThrowsAsync(() => + InsertResolutionAsync( + connectionString, + legacySourceId, + staffMemberId, + legacySourceId, + completedAtUtc.AddMinutes(2))); + Assert.Equal(PostgresErrorCodes.CheckViolation, wrongEventFailure.SqlState); + + PostgresException arbitraryEventFailure = + await Assert.ThrowsAsync(() => + InsertResolutionAsync( + connectionString, + legacySourceId, + staffMemberId, + Guid.Parse("51000000-0000-0000-0000-000000000001"), + completedAtUtc.AddMinutes(2))); + Assert.Equal( + "P0001", + arbitraryEventFailure.SqlState); + Assert.Contains( + "does not match its exact anchor coordinates", + arbitraryEventFailure.MessageText, + StringComparison.Ordinal); + + PostgresException wrongTargetFailure = + await Assert.ThrowsAsync(() => + InsertResolutionAsync( + connectionString, + legacySourceId, + otherStaffMemberId, + resolutionEventId, + completedAtUtc.AddMinutes(2))); + Assert.Equal( + PostgresErrorCodes.ForeignKeyViolation, + wrongTargetFailure.SqlState); + + PostgresException predatingFailure = + await Assert.ThrowsAsync(() => + InsertResolutionAsync( + connectionString, + legacySourceId, + staffMemberId, + resolutionEventId, + completedAtUtc.AddTicks(-10))); + Assert.Equal("P0001", predatingFailure.SqlState); + Assert.Contains( + "does not match its exact anchor coordinates", + predatingFailure.MessageText, + StringComparison.Ordinal); + + await InsertResolutionAsync( + connectionString, + legacySourceId, + staffMemberId, + resolutionEventId, + completedAtUtc.AddMinutes(2)).ConfigureAwait(false); + + await InsertResolutionAsync( + connectionString, + resumeSourceId, + otherStaffMemberId, + resumeResolutionEventId, + completedAtUtc.AddMinutes(2)).ConfigureAwait(false); + await ExecuteSqlAsync( + connectionString, + """ + UPDATE staff.staff_members + SET "Status" = 2, + "SuspendedAtUtc" = '2026-08-11T12:04:00Z', + "Version" = "Version" + 1 + WHERE "ScopeId" = @scopeId + AND "Id" = @staffMemberId; + """, + ("scopeId", TenantId), + ("staffMemberId", otherStaffMemberId)).ConfigureAwait(false); + await ExecuteSqlAsync( + connectionString, + """ + UPDATE staff.staff_members + SET "Status" = 1, + "SuspendedAtUtc" = NULL, + "Version" = "Version" + 1 + WHERE "ScopeId" = @scopeId + AND "Id" = @staffMemberId; + """, + ("scopeId", TenantId), + ("staffMemberId", otherStaffMemberId)).ConfigureAwait(false); + Assert.Equal( + 1, + await ReadStatusAsync( + connectionString, + otherStaffMemberId).ConfigureAwait(false)); + + await ExecuteSqlAsync( + connectionString, + """ + UPDATE staff.staff_members + SET "AuthSubjectId" = NULL, + "Status" = 4, + "SuspendedAtUtc" = NULL, + "DepartedAtUtc" = '2026-08-11T12:05:00Z', + "DepartureEffectiveOn" = DATE '2026-08-11', + "AnonymisedAtUtc" = '2026-08-11T12:06:00Z', + "Version" = "Version" + 1 + WHERE "ScopeId" = @scopeId + AND "Id" = @staffMemberId; + """, + ("scopeId", TenantId), + ("staffMemberId", otherStaffMemberId)).ConfigureAwait(false); + Assert.Equal( + 4, + await ReadStatusAsync( + connectionString, + otherStaffMemberId).ConfigureAwait(false)); + Assert.Null( + await ReadAuthSubjectAsync( + connectionString, + otherStaffMemberId).ConfigureAwait(false)); + + PostgresException postAnonymisationRelinkFailure = + await Assert.ThrowsAsync(() => + ExecuteSqlAsync( + connectionString, + """ + UPDATE staff.staff_members + SET "AuthSubjectId" = 'subject:forbidden-post-anonymisation', + "Version" = "Version" + 1 + WHERE "ScopeId" = @scopeId + AND "Id" = @staffMemberId; + """, + ("scopeId", TenantId), + ("staffMemberId", otherStaffMemberId))); + Assert.Equal("P0001", postAnonymisationRelinkFailure.SqlState); + Assert.Contains( + "Workspace onboarding anchor requires access closure before Staff Auth subject change", + postAnonymisationRelinkFailure.MessageText, + StringComparison.Ordinal); + Assert.Null( + await ReadAuthSubjectAsync( + connectionString, + otherStaffMemberId).ConfigureAwait(false)); + Assert.Equal( + 4, + await ReadStatusAsync( + connectionString, + otherStaffMemberId).ConfigureAwait(false)); + + Guid otherResolutionEventId = + Guid.Parse("51000000-0000-0000-0000-000000000002"); + await InsertAnchorAsync( + connectionString, + otherSourceId, + otherStaffMemberId, + completedAtUtc.AddMinutes(1), + otherResolutionEventId).ConfigureAwait(false); + Guid crossTenantStaffMemberId = + Guid.Parse("21000000-0000-0000-0000-000000000004"); + await using (StaffDbContext otherTenant = CreateDbContext( + connectionString, + "bunkfy-anchor-cross-tenant-setup", + OtherTenantId)) + { + await SeedMemberAsync( + otherTenant, + crossTenantStaffMemberId, + "subject:cross-tenant-anchor-target", + "Cross Tenant Anchor Target", + OtherTenantId).ConfigureAwait(false); + await otherTenant.SaveChangesAsync().ConfigureAwait(false); + } + + PostgresException crossTenantAnchorEventFailure = + await Assert.ThrowsAsync(() => + InsertAnchorAsync( + connectionString, + Guid.Parse("31000000-0000-0000-0000-000000000004"), + crossTenantStaffMemberId, + completedAtUtc.AddMinutes(1), + otherResolutionEventId, + OtherTenantId)); + Assert.Equal( + PostgresErrorCodes.UniqueViolation, + crossTenantAnchorEventFailure.SqlState); + PostgresException crossAnchorEventFailure = + await Assert.ThrowsAsync(() => + InsertResolutionAsync( + connectionString, + otherSourceId, + otherStaffMemberId, + resolutionEventId, + completedAtUtc.AddMinutes(3))); + Assert.Equal( + PostgresErrorCodes.UniqueViolation, + crossAnchorEventFailure.SqlState); + Assert.True(await IsResolutionEventIndexUniqueAsync(connectionString) + .ConfigureAwait(false)); + + await using (NpgsqlConnection resolvedWriter = new(connectionString)) + { + await resolvedWriter.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction transaction = + await resolvedWriter.BeginTransactionAsync() + .ConfigureAwait(false); + await InsertMutationOperationAsync( + resolvedWriter, + transaction, + oldAuthMutationId, + staffMemberId, + kind: 2, + fingerprintCharacter: 'b', + completedAtUtc.AddMinutes(3)).ConfigureAwait(false); + PostgresException resolvedWriterFailure = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + resolvedWriter, + transaction, + """ + UPDATE staff.staff_members + SET "AuthSubjectId" = 'subject:resolved-mutation', + "Version" = "Version" + 1 + WHERE "ScopeId" = @scopeId + AND "Id" = @staffMemberId; + """, + ("scopeId", TenantId), + ("staffMemberId", staffMemberId))); + Assert.Equal("P0001", resolvedWriterFailure.SqlState); + Assert.Contains( + "Workspace onboarding anchor requires access closure before Staff Auth subject change", + resolvedWriterFailure.MessageText, + StringComparison.Ordinal); + await transaction.RollbackAsync().ConfigureAwait(false); + } + + await ExecuteSqlAsync( + connectionString, + """ + DELETE FROM staff.member_mutation_operations + WHERE "ScopeId" = @scopeId + AND "StaffMemberId" = @staffMemberId + AND "Id" = @sourceId; + """, + ("scopeId", TenantId), + ("staffMemberId", staffMemberId), + ("sourceId", legacySourceId)).ConfigureAwait(false); + Assert.Equal( + 0L, + await CountMutationOperationAsync( + connectionString, + legacySourceId).ConfigureAwait(false)); + Assert.Equal( + "subject:legacy-anchor-target", + await ReadAuthSubjectAsync( + connectionString, + staffMemberId).ConfigureAwait(false)); + Assert.Equal( + 0L, + await CountMutationOperationAsync( + connectionString, + oldAuthMutationId).ConfigureAwait(false)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Downgrade_waits_for_concurrent_insert_then_fails_without_dropping_anchor() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_staff_anchor_down_race_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + string baseConnectionString = postgreSql.GetConnectionString(); + Guid staffMemberId = + Guid.Parse("20000000-0000-0000-0000-000000000001"); + Guid sourceId = + Guid.Parse("30000000-0000-0000-0000-000000000001"); + await using (StaffDbContext setup = CreateDbContext( + baseConnectionString, + "bunkfy-anchor-down-setup")) + { + await setup.Database.MigrateAsync().ConfigureAwait(false); + StaffMember member = StaffMember.Create( + staffMemberId, + TenantId, + "Anchor Target", + "Anchor Target", + "anchor-target@example.test", + null, + "ANCHOR-001", + "Owner", + "Operations", + "subject:anchor-target", + "system:migration-test", + Guid.Parse("40000000-0000-0000-0000-000000000001"), + new DateTimeOffset(2026, 8, 11, 12, 0, 0, TimeSpan.Zero)) + .Value; + var repository = new StaffMemberRepository(setup); + await repository.AddAsync(member, CancellationToken.None) + .ConfigureAwait(false); + await setup.SaveChangesAsync().ConfigureAwait(false); + } + + await using NpgsqlConnection inserter = new( + WithApplicationName(baseConnectionString, "bunkfy-anchor-inserter")); + await inserter.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction insertTransaction = + await inserter.BeginTransactionAsync().ConfigureAwait(false); + await using (NpgsqlCommand insert = new( + """ + INSERT INTO staff.identity_provisioning_anchors + ("ScopeId", "SourceKind", "SourceId", + "StaffMemberId", "AnchoredAtUtc", + "ResolutionEventId") + VALUES + (@scopeId, 1, @sourceId, @staffMemberId, + @anchoredAtUtc, @resolutionEventId); + """, + inserter, + insertTransaction)) + { + insert.Parameters.AddWithValue("scopeId", TenantId); + insert.Parameters.AddWithValue("sourceId", sourceId); + insert.Parameters.AddWithValue("staffMemberId", staffMemberId); + insert.Parameters.AddWithValue( + "resolutionEventId", + Guid.Parse("50000000-0000-0000-0000-000000000001")); + insert.Parameters.AddWithValue( + "anchoredAtUtc", + new DateTimeOffset(2026, 8, 11, 12, 1, 0, TimeSpan.Zero)); + await insert.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + await using StaffDbContext downgrade = CreateDbContext( + baseConnectionString, + "bunkfy-anchor-down-migration"); + Task downgradeTask = downgrade.Database.GetService() + .MigrateAsync(PreviousMigration); + + await WaitForBlockedAnchorAccessExclusiveAsync(baseConnectionString) + .ConfigureAwait(false); + Assert.False(downgradeTask.IsCompleted); + + await insertTransaction.CommitAsync().ConfigureAwait(false); + Exception failure = await Assert.ThrowsAnyAsync( + async () => await downgradeTask.ConfigureAwait(false)) + .ConfigureAwait(false); + Assert.Contains( + "Cannot remove Staff identity provisioning anchors while durable anchors exist", + failure.ToString(), + StringComparison.Ordinal); + + await using NpgsqlConnection verifier = new(baseConnectionString); + await verifier.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand count = new( + """ + SELECT COUNT(*) + FROM staff.identity_provisioning_anchors + WHERE "ScopeId" = @scopeId + AND "SourceKind" = 1 + AND "SourceId" = @sourceId + AND "StaffMemberId" = @staffMemberId; + """, + verifier); + count.Parameters.AddWithValue("scopeId", TenantId); + count.Parameters.AddWithValue("sourceId", sourceId); + count.Parameters.AddWithValue("staffMemberId", staffMemberId); + Assert.Equal(1L, await count.ExecuteScalarAsync().ConfigureAwait(false)); + } + + private static async Task SeedMemberAsync( + StaffDbContext context, + Guid staffMemberId, + string authSubjectId, + string displayName, + string scopeId = TenantId) + { + StaffMember member = StaffMember.Create( + staffMemberId, + scopeId, + displayName, + displayName, + $"{staffMemberId:N}@example.test", + null, + $"EMP-{staffMemberId:N}", + "Owner", + "Operations", + authSubjectId, + "system:migration-test", + Guid.NewGuid(), + new DateTimeOffset(2026, 8, 11, 12, 0, 0, TimeSpan.Zero)) + .Value; + await new StaffMemberRepository(context) + .AddAsync(member, CancellationToken.None) + .ConfigureAwait(false); + } + + private static async Task LockLegacyLifecycleCoordinatesAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid staffMemberId) + { + await ExecuteNonQueryAsync( + connection, + transaction, + """ + SELECT "Revision" + FROM staff.staff_operation_locks + WHERE "ScopeId" = @scopeId + AND "StaffMemberId" = @staffMemberId + FOR UPDATE; + """, + ("scopeId", TenantId), + ("staffMemberId", staffMemberId)).ConfigureAwait(false); + await ExecuteNonQueryAsync( + connection, + transaction, + """ + SELECT "Revision" + FROM staff.tenant_revisions + WHERE "ScopeId" = @scopeId + FOR SHARE; + """, + ("scopeId", TenantId)).ConfigureAwait(false); + } + + private static async Task InsertMutationOperationAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid operationId, + Guid staffMemberId, + int kind, + char fingerprintCharacter, + DateTimeOffset completedAtUtc) => + await ExecuteNonQueryAsync( + connection, + transaction, + """ + INSERT INTO staff.member_mutation_operations + ("ScopeId", "StaffMemberId", "Id", "Kind", + "ExpectedVersion", "RequestFingerprint", "ResultStatus", + "ResultVersion", "CompletedAtUtc") + VALUES + (@scopeId, @staffMemberId, @operationId, @kind, + 1, @fingerprint, 1, 1, @completedAtUtc); + """, + ("scopeId", TenantId), + ("staffMemberId", staffMemberId), + ("operationId", operationId), + ("kind", kind), + ("fingerprint", new string(fingerprintCharacter, 64)), + ("completedAtUtc", completedAtUtc)).ConfigureAwait(false); + + private static async Task InsertLegacyStaffMemberAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid staffMemberId) => + await ExecuteNonQueryAsync( + connection, + transaction, + """ + INSERT INTO staff.staff_members + ("Id", "DisplayName", "DisplayNameSearch", "AuthSubjectId", + "Status", "Version", "CreatedBy", "CreatedAtUtc", + "LastChangedBy", "LastChangedAtUtc", "ScopeId") + VALUES + (@staffMemberId, 'Legacy Created Anchor Target', + 'legacy created anchor target', + 'subject:legacy-created-anchor-target', 1, 1, + 'system:migration-test', '2026-08-11T12:00:00Z', + 'system:migration-test', '2026-08-11T12:00:00Z', + @scopeId); + """, + ("staffMemberId", staffMemberId), + ("scopeId", TenantId)).ConfigureAwait(false); + + private static async Task InsertLegacyOutboxAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid eventId, + string eventType) => + await ExecuteNonQueryAsync( + connection, + transaction, + """ + INSERT INTO staff.outbox_messages + ("Id", "Subject", "EventType", "Version", "ScopeId", + "OccurredAtUtc", "CreatedAtUtc", "Payload", "Attempts") + VALUES + (@eventId, 'staff.lifecycle.migration-proof', @eventType, 1, + @scopeId, '2026-08-11T12:01:00Z', + '2026-08-11T12:01:00Z', '{}', 0); + """, + ("eventId", eventId), + ("eventType", eventType), + ("scopeId", TenantId)).ConfigureAwait(false); + + private static async Task AdvanceTenantRevisionAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction) => + await ExecuteNonQueryAsync( + connection, + transaction, + """ + UPDATE staff.tenant_revisions + SET "Revision" = "Revision" + 1 + WHERE "ScopeId" = @scopeId; + """, + ("scopeId", TenantId)).ConfigureAwait(false); + + private static async Task AssertUpgradeLockFailureAsync( + string connectionString, + string applicationName) + { + await using StaffDbContext upgrade = CreateDbContext( + connectionString, + applicationName); + Exception failure = await Assert.ThrowsAnyAsync(() => + upgrade.Database.GetService().MigrateAsync()); + PostgresException postgres = Assert.IsType( + failure.GetBaseException()); + Assert.Equal(PostgresErrorCodes.LockNotAvailable, postgres.SqlState); + } + + private static async Task AssertAnchorMigrationNotAppliedAsync( + string connectionString) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new( + """ + SELECT to_regclass('staff.identity_provisioning_anchors') IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM staff.__ef_migrations_history + WHERE "MigrationId" = @migrationId); + """, + connection); + command.Parameters.AddWithValue("migrationId", CurrentMigration); + Assert.True((bool)(await command.ExecuteScalarAsync() + .ConfigureAwait(false))!); + } + + private static async Task + InsertKind8AndCommitExpectFailureAsync( + string connectionString, + Guid operationId, + Guid staffMemberId) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction transaction = + await connection.BeginTransactionAsync().ConfigureAwait(false); + await InsertMutationOperationAsync( + connection, + transaction, + operationId, + staffMemberId, + kind: 8, + fingerprintCharacter: 'c', + new DateTimeOffset(2026, 8, 11, 12, 4, 0, TimeSpan.Zero)) + .ConfigureAwait(false); + return await Assert.ThrowsAsync(() => + transaction.CommitAsync()) + .ConfigureAwait(false); + } + + private static async Task InsertKind8AndExactAnchorAsync( + string connectionString, + Guid operationId, + Guid staffMemberId, + Guid resolutionEventId, + DateTimeOffset completedAtUtc) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction transaction = + await connection.BeginTransactionAsync().ConfigureAwait(false); + await InsertMutationOperationAsync( + connection, + transaction, + operationId, + staffMemberId, + kind: 8, + fingerprintCharacter: 'd', + completedAtUtc).ConfigureAwait(false); + await ExecuteNonQueryAsync( + connection, + transaction, + """ + INSERT INTO staff.identity_provisioning_anchors + ("ScopeId", "SourceKind", "SourceId", "StaffMemberId", + "AnchoredAtUtc", "ResolutionEventId") + VALUES + (@scopeId, 1, @sourceId, @staffMemberId, + @anchoredAtUtc, @resolutionEventId); + """, + ("scopeId", TenantId), + ("sourceId", operationId), + ("staffMemberId", staffMemberId), + ("anchoredAtUtc", completedAtUtc), + ("resolutionEventId", resolutionEventId)).ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); + } + + private static async Task + DeleteKind8AndCommitExpectFailureAsync( + string connectionString, + Guid operationId, + Guid staffMemberId) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction transaction = + await connection.BeginTransactionAsync().ConfigureAwait(false); + Assert.Equal( + 1, + await ExecuteNonQueryAsync( + connection, + transaction, + """ + DELETE FROM staff.member_mutation_operations + WHERE "ScopeId" = @scopeId + AND "StaffMemberId" = @staffMemberId + AND "Id" = @operationId; + """, + ("scopeId", TenantId), + ("staffMemberId", staffMemberId), + ("operationId", operationId)).ConfigureAwait(false)); + return await Assert.ThrowsAsync(() => + transaction.CommitAsync()) + .ConfigureAwait(false); + } + + private static async Task InsertResolutionAsync( + string connectionString, + Guid sourceId, + Guid staffMemberId, + Guid resolutionEventId, + DateTimeOffset resolvedAtUtc) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction transaction = + await connection.BeginTransactionAsync().ConfigureAwait(false); + await ExecuteNonQueryAsync( + connection, + transaction, + """ + INSERT INTO staff.identity_provisioning_anchor_resolutions + ("ScopeId", "SourceKind", "SourceId", "StaffMemberId", + "WorkspaceApplicationVersion", "Disposition", + "ResolutionEventId", "ResolvedAtUtc") + VALUES + (@scopeId, 1, @sourceId, @staffMemberId, + 1, 1, @resolutionEventId, @resolvedAtUtc); + """, + ("scopeId", TenantId), + ("sourceId", sourceId), + ("staffMemberId", staffMemberId), + ("resolutionEventId", resolutionEventId), + ("resolvedAtUtc", resolvedAtUtc)).ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); + } + + private static async Task InsertAnchorAsync( + string connectionString, + Guid sourceId, + Guid staffMemberId, + DateTimeOffset anchoredAtUtc, + Guid resolutionEventId, + string scopeId = TenantId) => await ExecuteSqlAsync( + connectionString, + """ + INSERT INTO staff.identity_provisioning_anchors + ("ScopeId", "SourceKind", "SourceId", "StaffMemberId", + "AnchoredAtUtc", "ResolutionEventId") + VALUES + (@scopeId, 1, @sourceId, @staffMemberId, @anchoredAtUtc, + @resolutionEventId); + """, + ("scopeId", scopeId), + ("sourceId", sourceId), + ("staffMemberId", staffMemberId), + ("anchoredAtUtc", anchoredAtUtc), + ("resolutionEventId", resolutionEventId)).ConfigureAwait(false); + + private static async Task IsResolutionEventIndexUniqueAsync( + string connectionString) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new( + """ + SELECT EXISTS ( + SELECT 1 + FROM pg_indexes + WHERE schemaname = 'staff' + AND tablename = 'identity_provisioning_anchor_resolutions' + AND indexdef ILIKE 'CREATE UNIQUE INDEX%' + AND indexdef LIKE '%("ResolutionEventId")%' + ); + """, + connection); + return Assert.IsType( + await command.ExecuteScalarAsync().ConfigureAwait(false)); + } + + private static async Task AssertExactAnchorAsync( + string connectionString, + Guid sourceId, + Guid staffMemberId, + DateTimeOffset anchoredAtUtc) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new( + """ + SELECT "AnchoredAtUtc", "ResolutionEventId" + FROM staff.identity_provisioning_anchors + WHERE "ScopeId" = @scopeId + AND "SourceKind" = 1 + AND "SourceId" = @sourceId + AND "StaffMemberId" = @staffMemberId; + """, + connection); + command.Parameters.AddWithValue("scopeId", TenantId); + command.Parameters.AddWithValue("sourceId", sourceId); + command.Parameters.AddWithValue("staffMemberId", staffMemberId); + await using NpgsqlDataReader reader = await command.ExecuteReaderAsync() + .ConfigureAwait(false); + Assert.True(await reader.ReadAsync().ConfigureAwait(false)); + Assert.Equal(anchoredAtUtc.UtcDateTime, reader.GetDateTime(0)); + Guid resolutionEventId = reader.GetGuid(1); + Assert.NotEqual(Guid.Empty, resolutionEventId); + Assert.NotEqual(sourceId, resolutionEventId); + Assert.False(await reader.ReadAsync().ConfigureAwait(false)); + return resolutionEventId; + } + + private static async Task CountMutationOperationAsync( + string connectionString, + Guid operationId) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new( + """ + SELECT COUNT(*) + FROM staff.member_mutation_operations + WHERE "ScopeId" = @scopeId + AND "Id" = @operationId; + """, + connection); + command.Parameters.AddWithValue("scopeId", TenantId); + command.Parameters.AddWithValue("operationId", operationId); + return Convert.ToInt64( + await command.ExecuteScalarAsync().ConfigureAwait(false), + System.Globalization.CultureInfo.InvariantCulture); + } + + private static async Task ReadAuthSubjectAsync( + string connectionString, + Guid staffMemberId) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new( + """ + SELECT "AuthSubjectId" + FROM staff.staff_members + WHERE "ScopeId" = @scopeId + AND "Id" = @staffMemberId; + """, + connection); + command.Parameters.AddWithValue("scopeId", TenantId); + command.Parameters.AddWithValue("staffMemberId", staffMemberId); + return await command.ExecuteScalarAsync().ConfigureAwait(false) + as string; + } + + private static async Task ReadStatusAsync( + string connectionString, + Guid staffMemberId) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new( + """ + SELECT "Status" + FROM staff.staff_members + WHERE "ScopeId" = @scopeId + AND "Id" = @staffMemberId; + """, + connection); + command.Parameters.AddWithValue("scopeId", TenantId); + command.Parameters.AddWithValue("staffMemberId", staffMemberId); + return Assert.IsType( + await command.ExecuteScalarAsync().ConfigureAwait(false)); + } + + private static async Task ExecuteSqlAsync( + string connectionString, + string commandText, + params (string Name, object Value)[] parameters) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await ExecuteNonQueryAsync( + connection, + transaction: null, + commandText, + parameters).ConfigureAwait(false); + } + + private static async Task ExecuteNonQueryAsync( + NpgsqlConnection connection, + NpgsqlTransaction? transaction, + string commandText, + params (string Name, object Value)[] parameters) + { + await using NpgsqlCommand command = new( + commandText, + connection, + transaction); + foreach ((string name, object value) in parameters) + { + command.Parameters.AddWithValue(name, value); + } + + return await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + private static async Task WaitForBlockedAnchorAccessExclusiveAsync( + string connectionString) + { + await using NpgsqlConnection observer = new(connectionString); + await observer.OpenAsync().ConfigureAwait(false); + DateTimeOffset deadline = DateTimeOffset.UtcNow.AddSeconds(20); + while (DateTimeOffset.UtcNow < deadline) + { + await using NpgsqlCommand command = new( + """ + SELECT EXISTS ( + SELECT 1 + FROM pg_locks lock + INNER JOIN pg_stat_activity activity + ON activity.pid = lock.pid + WHERE activity.application_name = + 'bunkfy-anchor-down-migration' + AND lock.relation = + 'staff.identity_provisioning_anchors'::regclass + AND lock.mode = 'AccessExclusiveLock' + AND NOT lock.granted); + """, + observer); + if ((bool)(await command.ExecuteScalarAsync() + .ConfigureAwait(false))!) + { + return; + } + + await Task.Delay(TimeSpan.FromMilliseconds(25)) + .ConfigureAwait(false); + } + + throw new TimeoutException( + "The downgrade never waited for the anchor AccessExclusive lock."); + } + + private static StaffDbContext CreateDbContext( + string connectionString, + string applicationName, + string scopeId = TenantId) + { + DbContextOptions options = + new DbContextOptionsBuilder() + .UseNpgsql( + WithApplicationName(connectionString, applicationName), + provider => provider + .MigrationsAssembly( + StaffMigrations.PostgreSqlAssembly) + .MigrationsHistoryTable( + StaffMigrations.HistoryTable, + StaffMigrations.Schema)) + .Options; + return new( + options, + new TestScopeContext(scopeId), + OpenWorkspaceTerminationFenceReader.Instance); + } + + private static string WithApplicationName( + string connectionString, + string applicationName) + { + NpgsqlConnectionStringBuilder builder = new(connectionString) + { + ApplicationName = applicationName, + }; + return builder.ConnectionString; + } + + private sealed class TestScopeContext(string scopeId) : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => scopeId; + } +} diff --git a/tests/Integration.Tests/Staff/StaffIdentityProvisioningAnchorMigrationScriptTests.cs b/tests/Integration.Tests/Staff/StaffIdentityProvisioningAnchorMigrationScriptTests.cs new file mode 100644 index 00000000..cf52c60f --- /dev/null +++ b/tests/Integration.Tests/Staff/StaffIdentityProvisioningAnchorMigrationScriptTests.cs @@ -0,0 +1,295 @@ +namespace Integration.Tests.Staff; + +using BunkFy.Modules.Staff.Persistence; +using Gma.Framework.Scoping; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using System.Text.RegularExpressions; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class StaffIdentityProvisioningAnchorMigrationScriptTests +{ + private const string PreviousMigration = + "20260811045655_AddStaffOnboardingProvisioningOperations"; + private const string AnchorMigration = + "20260811110753_AddStaffIdentityProvisioningAnchors"; + + [Fact] + public void Upgrade_requires_fail_fast_writer_drain_and_fails_all_Closing_stages() + { + string script = NormalizeWhitespace(GenerateUpgradeScript()); + + Assert.Contains( + "LOCK TABLE \"staff\".\"tenant_revisions\" IN SHARE MODE NOWAIT", + script, + StringComparison.Ordinal); + Assert.Contains( + "LOCK TABLE \"staff\".\"member_mutation_operations\" IN SHARE MODE NOWAIT", + script, + StringComparison.Ordinal); + Assert.Contains( + "LOCK TABLE \"staff\".\"staff_members\" IN SHARE MODE NOWAIT", + script, + StringComparison.Ordinal); + Assert.Contains( + "WHERE state.\"LifecycleStatus\" = 2", + script, + StringComparison.Ordinal); + Assert.Contains( + "Cannot install Staff identity provisioning anchors while a tenant destruction is in progress", + script, + StringComparison.Ordinal); + int createTable = script.IndexOf( + "CREATE TABLE", + StringComparison.Ordinal); + Assert.True(createTable > 0); + Assert.DoesNotContain( + "operation.\"Stage\"", + script[..createTable], + StringComparison.Ordinal); + } + + [Theory] + [InlineData(17)] + [InlineData(18)] + public void Upgrade_does_not_bypass_an_in_flight_legacy_destroy_stage( + int legacyStage) + { + string script = NormalizeWhitespace(GenerateUpgradeScript()); + + Assert.Contains( + "WHERE state.\"LifecycleStatus\" = 2", + script, + StringComparison.Ordinal); + Assert.DoesNotContain( + $"\"Stage\" = {legacyStage}", + script, + StringComparison.Ordinal); + } + + [Fact] + public void Backfill_is_same_schema_exact_Kind8_and_excludes_closed_scopes() + { + string script = NormalizeWhitespace(GenerateUpgradeScript()); + int backfillStart = script.IndexOf( + "INSERT INTO \"staff\".\"identity_provisioning_anchors\"", + StringComparison.Ordinal); + int triggerStart = script.IndexOf( + "CREATE FUNCTION \"staff\".prevent_identity_provisioning_anchor_mutation", + backfillStart, + StringComparison.Ordinal); + Assert.True(backfillStart >= 0); + Assert.True(triggerStart > backfillStart); + string backfill = script[backfillStart..triggerStart]; + + Assert.Contains( + "FROM \"staff\".\"member_mutation_operations\" operation", + backfill, + StringComparison.Ordinal); + Assert.Contains( + "operation.\"Kind\" = 8", + backfill, + StringComparison.Ordinal); + Assert.Contains( + "COALESCE(state.\"LifecycleStatus\", 1) = 1", + backfill, + StringComparison.Ordinal); + Assert.DoesNotContain( + "workspaces", + backfill, + StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain( + "AuthSubject", + backfill, + StringComparison.Ordinal); + Assert.DoesNotContain( + "ON CONFLICT", + backfill, + StringComparison.Ordinal); + } + + [Fact] + public void Downgrade_serializes_close_destroy_and_anchor_insert_before_guard() + { + string script = NormalizeWhitespace(GenerateDowngradeScript()); + + int revisionLock = script.IndexOf( + "LOCK TABLE \"staff\".\"tenant_revisions\" IN SHARE MODE", + StringComparison.Ordinal); + int destroyLock = script.IndexOf( + "LOCK TABLE \"staff\".\"tenant_destroy_operations\" IN SHARE MODE", + StringComparison.Ordinal); + int anchorLock = script.IndexOf( + "LOCK TABLE \"staff\".\"identity_provisioning_anchors\"", + StringComparison.Ordinal); + int guard = script.IndexOf( + "Cannot remove Staff identity provisioning anchors while durable anchors exist", + StringComparison.Ordinal); + int stageRemap = script.IndexOf( + "UPDATE \"staff\".\"tenant_destroy_operations\"", + StringComparison.Ordinal); + int drop = script.LastIndexOf("DROP TABLE", StringComparison.Ordinal); + + Assert.True(revisionLock >= 0); + Assert.True(destroyLock > revisionLock); + Assert.True(anchorLock > destroyLock); + Assert.Contains("IN ACCESS EXCLUSIVE MODE", script, StringComparison.Ordinal); + Assert.True(guard > anchorLock); + Assert.True(stageRemap > guard); + Assert.True(drop > stageRemap); + Assert.Contains( + "identity_provisioning_anchors", + script[drop..], + StringComparison.Ordinal); + } + + [Fact] + public void Anchor_fk_and_immutable_trigger_require_the_exact_destroy_stage() + { + string script = NormalizeWhitespace(GenerateUpgradeScript()); + + Assert.Contains("ON DELETE RESTRICT", script, StringComparison.Ordinal); + Assert.Contains( + "destroy_stage = '24'", + script, + StringComparison.Ordinal); + Assert.Contains( + "operation.\"Stage\" IN (17, 24)", + script, + StringComparison.Ordinal); + Assert.Contains( + "state.\"LifecycleStatus\" = 2", + script, + StringComparison.Ordinal); + Assert.Contains( + "state.\"DestroyOperationId\" = operation.\"OperationId\"", + script, + StringComparison.Ordinal); + Assert.Contains( + "state.\"DestroyRequestSha256\" = operation.\"RequestSha256\"", + script, + StringComparison.Ordinal); + Assert.Contains( + "Staff identity provisioning anchors are immutable", + script, + StringComparison.Ordinal); + } + + [Fact] + public void Resolution_and_legacy_writer_backstops_are_deferred_and_exact() + { + string script = NormalizeWhitespace(GenerateUpgradeScript()); + + Assert.DoesNotContain( + "workspace_onboarding_resolution_event_id", + script, + StringComparison.Ordinal); + Assert.Contains("gen_random_uuid()", script, StringComparison.Ordinal); + Assert.Contains( + "CREATE UNIQUE INDEX \"IX_identity_provisioning_anchors_ResolutionEventId\"", + script, + StringComparison.Ordinal); + Assert.Contains( + "FOREIGN KEY (\"ScopeId\", \"SourceKind\", \"SourceId\", \"StaffMemberId\")", + script, + StringComparison.Ordinal); + Assert.Contains( + "destroy_stage = '25'", + script, + StringComparison.Ordinal); + Assert.Contains( + "DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION \"staff\".enforce_identity_provisioning_anchor_resolution_integrity()", + script, + StringComparison.Ordinal); + Assert.Contains( + "anchor.\"ResolutionEventId\" = NEW.\"ResolutionEventId\"", + script, + StringComparison.Ordinal); + Assert.Contains( + "DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION \"staff\".enforce_workspace_onboarding_receipt_anchor()", + script, + StringComparison.Ordinal); + Assert.Contains( + "Workspace onboarding receipt requires an exact Staff identity provisioning anchor", + script, + StringComparison.Ordinal); + Assert.Contains( + "Unresolved Workspace onboarding receipt cannot be removed", + script, + StringComparison.Ordinal); + Assert.Contains( + "Unresolved Workspace onboarding anchor blocks Staff identity lifecycle mutation", + script, + StringComparison.Ordinal); + Assert.Contains( + "Workspace onboarding anchor requires access closure before Staff Auth subject change", + script, + StringComparison.Ordinal); + Assert.Contains( + "NOT (OLD.\"Status\" <> 4 AND NEW.\"Status\" = 4 AND NEW.\"AuthSubjectId\" IS NULL)", + script, + StringComparison.Ordinal); + Assert.Contains( + "NEW.\"Status\" = 4 AND OLD.\"Status\" <> 4 AND NEW.\"AuthSubjectId\" IS NOT NULL", + script, + StringComparison.Ordinal); + Assert.Contains( + "NEW.\"Status\" = 1 AND OLD.\"Status\" <> 1", + script, + StringComparison.Ordinal); + } + + private static string GenerateUpgradeScript() + { + DbContextOptions options = + new DbContextOptionsBuilder() + .UseNpgsql( + "Host=127.0.0.1;Database=script_only;Username=script_only;Password=script_only", + provider => provider + .MigrationsAssembly( + StaffMigrations.PostgreSqlAssembly) + .MigrationsHistoryTable( + StaffMigrations.HistoryTable, + StaffMigrations.Schema)) + .Options; + using StaffDbContext context = new( + options, + new TestScopeContext()); + return context.Database.GetService().GenerateScript( + PreviousMigration, + AnchorMigration); + } + + private static string GenerateDowngradeScript() + { + DbContextOptions options = + new DbContextOptionsBuilder() + .UseNpgsql( + "Host=127.0.0.1;Database=script_only;Username=script_only;Password=script_only", + provider => provider + .MigrationsAssembly( + StaffMigrations.PostgreSqlAssembly) + .MigrationsHistoryTable( + StaffMigrations.HistoryTable, + StaffMigrations.Schema)) + .Options; + using StaffDbContext context = new( + options, + new TestScopeContext()); + return context.Database.GetService().GenerateScript( + AnchorMigration, + PreviousMigration); + } + + private static string NormalizeWhitespace(string value) => + Regex.Replace(value, "\\s+", " "); + + private sealed class TestScopeContext : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => + "10000000-0000-0000-0000-000000000001"; + } +} diff --git a/tests/Integration.Tests/Staff/StaffMemberMutationOperationIntegrationTests.cs b/tests/Integration.Tests/Staff/StaffMemberMutationOperationIntegrationTests.cs index a60ed889..32362a04 100644 --- a/tests/Integration.Tests/Staff/StaffMemberMutationOperationIntegrationTests.cs +++ b/tests/Integration.Tests/Staff/StaffMemberMutationOperationIntegrationTests.cs @@ -1,5 +1,6 @@ namespace Integration.Tests; +using System.Data.Common; using BunkFy.Modules.Staff.Application; using BunkFy.Modules.Staff.Application.Commands; using BunkFy.Modules.Staff.Application.Ports; @@ -16,6 +17,7 @@ namespace Integration.Tests; using Gma.Framework.Scoping; using Integration.Tests.Support; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.DependencyInjection; @@ -31,6 +33,294 @@ public sealed class StaffMemberMutationOperationIntegrationTests private const string OtherTenantId = "a9000000-0000-0000-0000-000000000002"; + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Lifecycle_writes_mutation_receipt_before_member() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_staff_lifecycle_write_order_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + StaffCommandOrderInterceptor interceptor = new(); + MutableScopeContext scopeContext = new(TenantId); + await using ServiceProvider services = CreateProvider( + postgreSql.GetConnectionString(), + scopeContext, + interceptor); + await MigrateAsync(services).ConfigureAwait(false); + + Guid staffMemberId = Guid.NewGuid(); + Result created = await SendAsync( + services, + new CreateStaffMemberCommand( + staffMemberId, + "Lifecycle Order", + null, + "lifecycle-order@example.test", + null, + null, + null, + null, + "integration:test")).ConfigureAwait(false); + Assert.True(created.IsSuccess, created.Error.Code); + + interceptor.Clear(); + Result suspended = await SendAsync( + services, + new SuspendStaffMemberCommand( + Guid.NewGuid(), + staffMemberId, + "Write order proof", + created.Value.Version, + "integration:test")).ConfigureAwait(false); + Assert.True(suspended.IsSuccess, suspended.Error.Code); + interceptor.AssertReceiptBeforeMember(); + + interceptor.Clear(); + Result resumed = await SendAsync( + services, + new ResumeStaffMemberCommand( + Guid.NewGuid(), + staffMemberId, + "Write order proof", + suspended.Value.Version, + "integration:test")).ConfigureAwait(false); + Assert.True(resumed.IsSuccess, resumed.Error.Code); + interceptor.AssertReceiptBeforeMember(); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Provisioning_reloads_subject_owner_after_member_lock() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_staff_provisioning_reload_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + string connectionString = postgreSql.GetConnectionString(); + MutableScopeContext scopeContext = new(TenantId); + await using ServiceProvider services = CreateProvider( + connectionString, + scopeContext); + await MigrateAsync(services).ConfigureAwait(false); + + Guid onboardingMemberId = Guid.NewGuid(); + const string onboardingSubject = "account-onboarding-race"; + await SeedMemberAsync( + services, + onboardingMemberId, + onboardingSubject, + "Race Applicant", + "race-applicant@example.test").ConfigureAwait(false); + + Guid applicationId = Guid.NewGuid(); + await using (NpgsqlConnection blocker = new(connectionString)) + { + await blocker.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction transaction = + await blocker.BeginTransactionAsync().ConfigureAwait(false); + await LockMemberOperationRowAsync( + blocker, + transaction, + onboardingMemberId).ConfigureAwait(false); + + Task> provisioning = SendAsync( + services, + new ProvisionStaffOnboardingCommand( + applicationId, + onboardingSubject, + "Race Applicant", + null, + "race-applicant@example.test", + null, + null, + null, + null, + "integration:workspaces")); + await WaitForMemberOperationLockAsync(connectionString) + .ConfigureAwait(false); + + await ExecuteSqlAsync( + connectionString, + $$""" + UPDATE staff.staff_members + SET "AuthSubjectId" = 'account-retargeted', + "Version" = "Version" + 1 + WHERE "ScopeId" = '{{TenantId}}' + AND "Id" = '{{onboardingMemberId:D}}'; + """).ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); + + Result result = + await provisioning.ConfigureAwait(false); + Assert.Equal( + StaffApplicationErrors.StaffMemberNotFound, + result.Error); + } + + await AssertNoWorkspaceAnchorSideEffectsAsync( + services, + applicationId).ConfigureAwait(false); + using (IServiceScope verificationScope = services.CreateScope()) + { + StaffMember persisted = await verificationScope.ServiceProvider + .GetRequiredService() + .StaffMembers.AsNoTracking() + .SingleAsync(member => member.Id == onboardingMemberId) + .ConfigureAwait(false); + Assert.Equal("account-retargeted", persisted.AuthSubjectId); + Assert.Equal( + "race-applicant@example.test", + persisted.WorkEmail); + Assert.Equal(2, persisted.Version); + } + + Guid ownerMemberId = Guid.NewGuid(); + const string ownerSubject = "account-owner-race"; + await SeedMemberAsync( + services, + ownerMemberId, + ownerSubject, + "Race Owner", + "race-owner@example.test").ConfigureAwait(false); + Guid ownerSourceId = Guid.NewGuid(); + await using (NpgsqlConnection blocker = new(connectionString)) + { + await blocker.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction transaction = + await blocker.BeginTransactionAsync().ConfigureAwait(false); + await LockMemberOperationRowAsync( + blocker, + transaction, + ownerMemberId).ConfigureAwait(false); + + Task> bootstrap = SendAsync( + services, + new BootstrapStaffIdentityCommand( + Guid.NewGuid(), + ownerSourceId, + ownerSubject, + "Race Owner", + "race-owner@example.test", + "integration:organizations")); + await WaitForMemberOperationLockAsync(connectionString) + .ConfigureAwait(false); + + await ExecuteSqlAsync( + connectionString, + $$""" + UPDATE staff.staff_members + SET "AuthSubjectId" = NULL, + "Status" = 2, + "SuspendedAtUtc" = '2026-08-11T12:05:00Z', + "Version" = "Version" + 1 + WHERE "ScopeId" = '{{TenantId}}' + AND "Id" = '{{ownerMemberId:D}}'; + """).ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); + + Result result = await bootstrap.ConfigureAwait(false); + Assert.Equal( + StaffApplicationErrors.CreationOperationConflict, + result.Error); + } + + using IServiceScope ownerVerificationScope = services.CreateScope(); + StaffDbContext verification = ownerVerificationScope.ServiceProvider + .GetRequiredService(); + Assert.False(await verification.IdentityProvisioningAnchors + .AsNoTracking() + .AnyAsync(anchor => + anchor.SourceKind == + StaffIdentityProvisioningSourceKind + .OrganizationMembership && + anchor.SourceId == ownerSourceId) + .ConfigureAwait(false)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Onboarding_writer_atomically_commits_exact_anchor_receipt_and_event_once() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_staff_onboarding_anchor_writer_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + MutableScopeContext scopeContext = new(TenantId); + await using ServiceProvider services = CreateProvider( + postgreSql.GetConnectionString(), + scopeContext); + await MigrateAsync(services).ConfigureAwait(false); + + Guid applicationId = Guid.NewGuid(); + ProvisionStaffOnboardingCommand command = new( + applicationId, + "account-anchor-writer", + "Anchor Writer", + null, + "anchor-writer@example.test", + null, + null, + null, + null, + "integration:workspaces"); + Result first = await SendAsync( + services, + command).ConfigureAwait(false); + Result replay = await SendAsync( + services, + command).ConfigureAwait(false); + Assert.True(first.IsSuccess, first.Error.Code); + Assert.True(replay.IsSuccess, replay.Error.Code); + Assert.Equal(first.Value.StaffMemberId, replay.Value.StaffMemberId); + + using IServiceScope verificationScope = services.CreateScope(); + StaffDbContext dbContext = verificationScope.ServiceProvider + .GetRequiredService(); + StaffIdentityProvisioningAnchor anchor = Assert.Single( + await dbContext.IdentityProvisioningAnchors + .AsNoTracking() + .Where(candidate => candidate.SourceKind == + StaffIdentityProvisioningSourceKind + .WorkspaceOnboarding && + candidate.SourceId == applicationId) + .ToArrayAsync() + .ConfigureAwait(false)); + StaffMemberMutationOperation receipt = Assert.Single( + await dbContext.MemberMutationOperations + .AsNoTracking() + .Where(candidate => candidate.Kind == + StaffMemberMutationKind.OnboardingProvision && + candidate.Id == applicationId) + .ToArrayAsync() + .ConfigureAwait(false)); + OutboxMessage anchorCreated = Assert.Single( + await dbContext.OutboxMessages + .AsNoTracking() + .Where(message => message.EventType.Contains( + nameof( + StaffIdentityProvisioningAnchorCreatedIntegrationEvent)) && + message.Payload.Contains(applicationId.ToString("D"))) + .ToArrayAsync() + .ConfigureAwait(false)); + Assert.Equal(first.Value.StaffMemberId, anchor.StaffMemberId); + Assert.Equal(anchor.StaffMemberId, receipt.StaffMemberId); + Assert.Equal(receipt.CompletedAtUtc, anchor.AnchoredAtUtc); + Assert.Contains( + anchor.StaffMemberId.ToString("D"), + anchorCreated.Payload, + StringComparison.Ordinal); + } + [DockerFact] [Trait("Category", "Docker")] [Trait("Category", "Integration")] @@ -49,9 +339,11 @@ public async Task Owner_identity_bootstrap_converges_under_exact_replay_and_comp await MigrateAsync(services).ConfigureAwait(false); Guid replayOperationId = Guid.NewGuid(); + Guid replayMembershipId = Guid.NewGuid(); const string replaySubjectId = "account-owner-replay"; BootstrapStaffIdentityCommand replay = new( replayOperationId, + replayMembershipId, replaySubjectId, "Workspace Owner", "owner-replay@example.test", @@ -66,12 +358,14 @@ public async Task Owner_identity_bootstrap_converges_under_exact_replay_and_comp Guid competingOperationIdA = Guid.NewGuid(); Guid competingOperationIdB = Guid.NewGuid(); + Guid competingMembershipId = Guid.NewGuid(); const string competingSubjectId = "account-owner-competing"; Result[] competingResults = await Task.WhenAll( SendAsync( services, new BootstrapStaffIdentityCommand( competingOperationIdA, + competingMembershipId, competingSubjectId, "Competing Owner A", "owner-competing@example.test", @@ -80,6 +374,7 @@ public async Task Owner_identity_bootstrap_converges_under_exact_replay_and_comp services, new BootstrapStaffIdentityCommand( competingOperationIdB, + competingMembershipId, competingSubjectId, "Competing Owner B", "owner-competing@example.test", @@ -1128,6 +1423,122 @@ private static async Task SeedActivePropertyAsync( await dbContext.SaveChangesAsync().ConfigureAwait(false); } + private static async Task SeedMemberAsync( + ServiceProvider services, + Guid staffMemberId, + string authSubjectId, + string displayName, + string workEmail) + { + using IServiceScope scope = services.CreateScope(); + StaffDbContext dbContext = scope.ServiceProvider + .GetRequiredService(); + StaffMember member = StaffMember.Create( + staffMemberId, + TenantId, + displayName, + legalName: null, + workEmail, + workPhone: null, + employeeNumber: null, + jobTitle: null, + department: null, + authSubjectId, + "system:integration-test", + Guid.NewGuid(), + new DateTimeOffset(2026, 8, 11, 12, 0, 0, TimeSpan.Zero)) + .Value; + await scope.ServiceProvider.GetRequiredService< + IStaffMemberRepository>() + .AddAsync(member, CancellationToken.None) + .ConfigureAwait(false); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + + private static async Task LockMemberOperationRowAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid staffMemberId) + { + await using NpgsqlCommand command = new( + """ + UPDATE staff.staff_operation_locks + SET "Revision" = "Revision" + WHERE "ScopeId" = @scopeId + AND "StaffMemberId" = @staffMemberId; + """, + connection, + transaction); + command.Parameters.AddWithValue("scopeId", TenantId); + command.Parameters.AddWithValue("staffMemberId", staffMemberId); + Assert.Equal( + 1, + await command.ExecuteNonQueryAsync().ConfigureAwait(false)); + } + + private static async Task WaitForMemberOperationLockAsync( + string connectionString) + { + await using NpgsqlConnection observer = new(connectionString); + await observer.OpenAsync().ConfigureAwait(false); + DateTimeOffset deadline = DateTimeOffset.UtcNow.AddSeconds(20); + while (DateTimeOffset.UtcNow < deadline) + { + await using NpgsqlCommand command = new( + """ + SELECT EXISTS ( + SELECT 1 + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND state = 'active' + AND wait_event_type = 'Lock' + AND query ILIKE '%operation_locks%'); + """, + observer); + if ((bool)(await command.ExecuteScalarAsync() + .ConfigureAwait(false))!) + { + return; + } + + await Task.Delay(TimeSpan.FromMilliseconds(25)) + .ConfigureAwait(false); + } + + throw new TimeoutException( + "The Staff handler did not wait for the member operation lock."); + } + + private static async Task AssertNoWorkspaceAnchorSideEffectsAsync( + ServiceProvider services, + Guid applicationId) + { + using IServiceScope scope = services.CreateScope(); + StaffDbContext dbContext = scope.ServiceProvider + .GetRequiredService(); + Assert.False(await dbContext.IdentityProvisioningAnchors + .AsNoTracking() + .AnyAsync(anchor => + anchor.SourceKind == + StaffIdentityProvisioningSourceKind + .WorkspaceOnboarding && + anchor.SourceId == applicationId) + .ConfigureAwait(false)); + Assert.False(await dbContext.MemberMutationOperations + .AsNoTracking() + .AnyAsync(operation => + operation.Kind == + StaffMemberMutationKind.OnboardingProvision && + operation.Id == applicationId) + .ConfigureAwait(false)); + Assert.False(await dbContext.OutboxMessages + .AsNoTracking() + .AnyAsync(message => message.Payload.Contains( + applicationId.ToString("D"))) + .ConfigureAwait(false)); + } + private static async Task> SendAsync( ServiceProvider services, ICommand command) @@ -1163,7 +1574,8 @@ private static async Task ExecuteSqlAsync( private static ServiceProvider CreateProvider( string connectionString, - MutableScopeContext scopeContext) + MutableScopeContext scopeContext, + DbCommandInterceptor? commandInterceptor = null) { HostApplicationBuilder builder = Host.CreateApplicationBuilder(); builder.Configuration["Persistence:Provider"] = "PostgreSql"; @@ -1177,10 +1589,99 @@ private static ServiceProvider CreateProvider( builder.AddMessagingInfrastructure(); builder.Services.AddStaffApplication(); builder.AddStaffPersistence(); + if (commandInterceptor is not null) + { + builder.Services.AddDbContext(options => + options.AddInterceptors(commandInterceptor)); + } + return builder.Services.BuildServiceProvider( new ServiceProviderOptions { ValidateScopes = true }); } + private sealed class StaffCommandOrderInterceptor : DbCommandInterceptor + { + private readonly Lock sync = new(); + private readonly List commands = []; + + public override ValueTask> + ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + this.Capture(command); + return ValueTask.FromResult(result); + } + + public override ValueTask> + NonQueryExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + this.Capture(command); + return ValueTask.FromResult(result); + } + + public override ValueTask> + ScalarExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + this.Capture(command); + return ValueTask.FromResult(result); + } + + public void Clear() + { + lock (this.sync) + { + this.commands.Clear(); + } + } + + public void AssertReceiptBeforeMember() + { + string commandText; + lock (this.sync) + { + commandText = string.Join( + "\n-- intercepted-command --\n", + this.commands); + } + + int memberUpdate = commandText.IndexOf( + "UPDATE staff.staff_members", + StringComparison.OrdinalIgnoreCase); + int operationInsert = commandText.IndexOf( + "INSERT INTO staff.member_mutation_operations", + StringComparison.OrdinalIgnoreCase); + + Assert.True( + memberUpdate >= 0, + $"Staff member UPDATE was not captured. SQL:\n{commandText}"); + Assert.True( + operationInsert >= 0, + $"Mutation receipt INSERT was not captured. SQL:\n{commandText}"); + Assert.True( + operationInsert < memberUpdate, + $"Expected mutation receipt INSERT before Staff member UPDATE. SQL:\n{commandText}"); + } + + private void Capture(DbCommand command) + { + lock (this.sync) + { + this.commands.Add(command.CommandText); + } + } + } + private sealed class MutableScopeContext(string scopeId) : IScopeContext { public bool IsEnabled => true; diff --git a/tests/Integration.Tests/Staff/StaffTenantTerminationIntegrationTests.cs b/tests/Integration.Tests/Staff/StaffTenantTerminationIntegrationTests.cs index 1c33231f..be3bf0b2 100644 --- a/tests/Integration.Tests/Staff/StaffTenantTerminationIntegrationTests.cs +++ b/tests/Integration.Tests/Staff/StaffTenantTerminationIntegrationTests.cs @@ -28,6 +28,14 @@ public sealed class StaffTenantTerminationIntegrationTests "10000000-0000-0000-0000-000000000001"; private const string TenantB = "10000000-0000-0000-0000-000000000002"; + private const string TenantSparseAnchor = + "10000000-0000-0000-0000-000000000003"; + private const string TenantSingleResolution = + "10000000-0000-0000-0000-000000000004"; + private const string TenantPagedResolutions = + "10000000-0000-0000-0000-000000000005"; + private const string TenantIdentityAnchorSentinel = + "10000000-0000-0000-0000-000000000006"; private const string Digest = "0123456789abcdef0123456789abcdef" + "0123456789abcdef0123456789abcdef"; @@ -443,6 +451,375 @@ FROM staff.tenant_revisions TenantA)); } + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Tenant_destroy_persists_sparse_and_paged_identity_anchor_stages() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_staff_anchor_destroy_stage_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + using (ServiceProvider sentinelProvider = CreatePersistenceProvider( + connectionString, + TenantIdentityAnchorSentinel, + new TestClock(ExportNowUtc))) + { + await SeedIdentityAnchorGraphAsync( + sentinelProvider, + TenantIdentityAnchorSentinel, + anchorCount: 1, + resolutionCount: 1) + .ConfigureAwait(false); + } + + DestroyStageSnapshot[] sparse = + await DestroyIdentityAnchorScenarioAsync( + connectionString, + TenantSparseAnchor, + anchorCount: 1, + resolutionCount: 0).ConfigureAwait(false); + DestroyStageSnapshot sparseAnchorBatch = Assert.Single( + sparse, + snapshot => snapshot.Status == + TenantTerminationContributionStatus.RetryRequired && + snapshot.Stage == 17 && + snapshot.ResolutionCount == 0 && + snapshot.AnchorCount == 0 && + snapshot.StaffMemberCount == 1); + Assert.Equal(17, sparseAnchorBatch.Stage); + + DestroyStageSnapshot[] single = + await DestroyIdentityAnchorScenarioAsync( + connectionString, + TenantSingleResolution, + anchorCount: 1, + resolutionCount: 1).ConfigureAwait(false); + Assert.Contains( + single, + snapshot => snapshot.Status == + TenantTerminationContributionStatus.RetryRequired && + snapshot.Stage == 24 && + snapshot.ResolutionCount == 0 && + snapshot.AnchorCount == 1); + Assert.Contains( + single, + snapshot => snapshot.Status == + TenantTerminationContributionStatus.RetryRequired && + snapshot.Stage == 17 && + snapshot.ResolutionCount == 0 && + snapshot.AnchorCount == 0); + + DestroyStageSnapshot[] paged = + await DestroyIdentityAnchorScenarioAsync( + connectionString, + TenantPagedResolutions, + anchorCount: 501, + resolutionCount: 501).ConfigureAwait(false); + Assert.Contains( + paged, + snapshot => snapshot.Status == + TenantTerminationContributionStatus.RetryRequired && + snapshot.Stage == 25 && + snapshot.ResolutionCount == 1 && + snapshot.AnchorCount == 501); + Assert.Contains( + paged, + snapshot => snapshot.Status == + TenantTerminationContributionStatus.RetryRequired && + snapshot.Stage == 24 && + snapshot.ResolutionCount == 0 && + snapshot.AnchorCount == 501); + Assert.Contains( + paged, + snapshot => snapshot.Status == + TenantTerminationContributionStatus.RetryRequired && + snapshot.Stage == 24 && + snapshot.ResolutionCount == 0 && + snapshot.AnchorCount == 1); + Assert.Contains( + paged, + snapshot => snapshot.Status == + TenantTerminationContributionStatus.RetryRequired && + snapshot.Stage == 17 && + snapshot.ResolutionCount == 0 && + snapshot.AnchorCount == 0); + Assert.Equal( + 1, + await CountForTenantAsync( + connectionString, + "staff.identity_provisioning_anchor_resolutions", + TenantIdentityAnchorSentinel).ConfigureAwait(false)); + Assert.Equal( + 1, + await CountForTenantAsync( + connectionString, + "staff.identity_provisioning_anchors", + TenantIdentityAnchorSentinel).ConfigureAwait(false)); + Assert.Equal( + 1, + await CountForTenantAsync( + connectionString, + "staff.staff_members", + TenantIdentityAnchorSentinel).ConfigureAwait(false)); + Assert.Equal( + 1, + await ScalarForTenantAsync( + connectionString, + """ + SELECT "LifecycleStatus"::bigint + FROM staff.tenant_revisions + WHERE "ScopeId" = @tenantId + """, + TenantIdentityAnchorSentinel).ConfigureAwait(false)); + } + + private static async Task + DestroyIdentityAnchorScenarioAsync( + string connectionString, + string tenantId, + int anchorCount, + int resolutionCount) + { + TestClock clock = new(ExportNowUtc); + using ServiceProvider provider = CreatePersistenceProvider( + connectionString, + tenantId, + clock); + await SeedIdentityAnchorGraphAsync( + provider, + tenantId, + anchorCount, + resolutionCount) + .ConfigureAwait(false); + + WorkspaceTerminationFence fence = await AddFenceAsync( + provider, + tenantId).ConfigureAwait(false); + TenantTerminationContributionRequest request = TenantDestroyRequest( + fence, + tenantId, + DeterministicSourceId(tenantId, 10_000)); + long selectedRevision = await ScalarForTenantAsync( + connectionString, + """ + SELECT "Revision" + FROM staff.tenant_revisions + WHERE "ScopeId" = @tenantId + """, + tenantId).ConfigureAwait(false); + using IServiceScope destroyScope = provider.CreateScope(); + ITenantTerminationContributor contributor = ResolveContributor( + destroyScope.ServiceProvider); + List snapshots = []; + for (int attempt = 0; attempt < 20; attempt++) + { + TenantTerminationContributionResult result = + await contributor.ExecuteAsync( + request, + CancellationToken.None).ConfigureAwait(false); + snapshots.Add(new( + result.Status, + await ReadDestroyStageAsync( + connectionString, + tenantId).ConfigureAwait(false), + await CountForTenantAsync( + connectionString, + "staff.identity_provisioning_anchor_resolutions", + tenantId).ConfigureAwait(false), + await CountForTenantAsync( + connectionString, + "staff.identity_provisioning_anchors", + tenantId).ConfigureAwait(false), + await CountForTenantAsync( + connectionString, + "staff.staff_members", + tenantId).ConfigureAwait(false))); + if (result.Status == TenantTerminationContributionStatus.Completed) + { + break; + } + + Assert.Equal( + TenantTerminationContributionStatus.RetryRequired, + result.Status); + } + + DestroyStageSnapshot completed = Assert.Single( + snapshots, + snapshot => snapshot.Status == + TenantTerminationContributionStatus.Completed); + Assert.Null(completed.Stage); + Assert.Equal(0, completed.ResolutionCount); + Assert.Equal(0, completed.AnchorCount); + Assert.Equal(0, completed.StaffMemberCount); + Assert.Equal( + 3, + await ScalarForTenantAsync( + connectionString, + """ + SELECT "LifecycleStatus"::bigint + FROM staff.tenant_revisions + WHERE "ScopeId" = @tenantId + """, + tenantId).ConfigureAwait(false)); + Assert.Equal( + 1, + await CountForTenantAsync( + connectionString, + "staff.tenant_destroy_receipts", + tenantId).ConfigureAwait(false)); + DestroyReceiptSnapshot receipt = await ReadDestroyReceiptAsync( + connectionString, + tenantId).ConfigureAwait(false); + Assert.Equal(request.IdempotencyKey, receipt.OperationId); + Assert.Equal(tenantId, receipt.ScopeId); + Assert.Equal(selectedRevision, receipt.SelectedRevision); + Assert.Equal(selectedRevision + 1, receipt.ResultingRevision); + return snapshots.ToArray(); + } + + private static async Task SeedIdentityAnchorGraphAsync( + ServiceProvider provider, + string tenantId, + int anchorCount, + int resolutionCount) + { + using IServiceScope seedScope = provider.CreateScope(); + StaffDbContext staff = seedScope.ServiceProvider + .GetRequiredService(); + WorkspacesDbContext workspaces = seedScope.ServiceProvider + .GetRequiredService(); + await staff.Database.MigrateAsync().ConfigureAwait(false); + await workspaces.Database.MigrateAsync().ConfigureAwait(false); + + StaffMember member = CreateMember( + tenantId, + $"Anchor target {tenantId[^1]}"); + staff.StaffMembers.Add(member); + await staff.SaveChangesAsync().ConfigureAwait(false); + + IStaffIdentityProvisioningAnchorRepository anchors = seedScope + .ServiceProvider.GetRequiredService< + IStaffIdentityProvisioningAnchorRepository>(); + IStaffIdentityProvisioningAnchorResolutionRepository resolutions = + seedScope.ServiceProvider.GetRequiredService< + IStaffIdentityProvisioningAnchorResolutionRepository>(); + Guid[] sourceIds = Enumerable.Range(1, anchorCount) + .Select(index => DeterministicSourceId(tenantId, index)) + .ToArray(); + foreach (Guid sourceId in sourceIds) + { + await anchors.AddAsync( + new StaffIdentityProvisioningAnchorRecord( + tenantId, + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + sourceId, + member.Id, + SeedNowUtc), + CancellationToken.None).ConfigureAwait(false); + } + + await staff.SaveChangesAsync().ConfigureAwait(false); + foreach (Guid sourceId in sourceIds.Take(resolutionCount)) + { + await resolutions.AddAsync( + new StaffIdentityProvisioningAnchorResolutionRecord( + tenantId, + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + sourceId, + member.Id, + WorkspaceApplicationVersion: 1, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + sourceId, + SeedNowUtc.AddMinutes(1)), + CancellationToken.None).ConfigureAwait(false); + } + + await staff.SaveChangesAsync().ConfigureAwait(false); + } + + private static Guid DeterministicSourceId( + string tenantId, + int ordinal) + { + Span bytes = stackalloc byte[16]; + Guid.Parse(tenantId).TryWriteBytes(bytes); + System.Buffers.Binary.BinaryPrimitives.WriteInt32BigEndian( + bytes, + ordinal); + return new Guid(bytes); + } + + private static async Task ReadDestroyStageAsync( + string connectionString, + string tenantId) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new( + """ + SELECT "Stage" + FROM staff.tenant_destroy_operations + WHERE "ScopeId" = @tenantId; + """, + connection); + command.Parameters.AddWithValue("tenantId", tenantId); + object? value = await command.ExecuteScalarAsync() + .ConfigureAwait(false); + return value is null or DBNull + ? null + : Convert.ToInt32( + value, + System.Globalization.CultureInfo.InvariantCulture); + } + + private static async Task ReadDestroyReceiptAsync( + string connectionString, + string tenantId) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new( + """ + SELECT + "OperationId", + "ScopeId", + "SelectedRevision", + "ResultingRevision" + FROM staff.tenant_destroy_receipts + WHERE "ScopeId" = @tenantId; + """, + connection); + command.Parameters.AddWithValue("tenantId", tenantId); + await using NpgsqlDataReader reader = await command.ExecuteReaderAsync() + .ConfigureAwait(false); + Assert.True(await reader.ReadAsync().ConfigureAwait(false)); + DestroyReceiptSnapshot snapshot = new( + reader.GetGuid(0), + reader.GetString(1), + reader.GetInt64(2), + reader.GetInt64(3)); + Assert.False(await reader.ReadAsync().ConfigureAwait(false)); + return snapshot; + } + + private sealed record DestroyStageSnapshot( + TenantTerminationContributionStatus Status, + int? Stage, + long ResolutionCount, + long AnchorCount, + long StaffMemberCount); + + private sealed record DestroyReceiptSnapshot( + Guid OperationId, + string ScopeId, + long SelectedRevision, + long ResultingRevision); + private static async Task<(Guid StaffId, ProofIds ProofIds)> SeedGraphAsync( StaffDbContext context, 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/WorkspaceStaffCorrelationAnonymisationPersistenceIntegrationTests.cs b/tests/Integration.Tests/Workspaces/WorkspaceStaffCorrelationAnonymisationPersistenceIntegrationTests.cs index 788458be..06515b05 100644 --- a/tests/Integration.Tests/Workspaces/WorkspaceStaffCorrelationAnonymisationPersistenceIntegrationTests.cs +++ b/tests/Integration.Tests/Workspaces/WorkspaceStaffCorrelationAnonymisationPersistenceIntegrationTests.cs @@ -270,7 +270,22 @@ private static async Task SeedOriginalStateAsync( await using WorkspacesDbContext dbContext = CreateDbContext(connectionString, TenantA); await dbContext.Database.MigrateAsync(); - SeedOriginalState(dbContext); + WorkspaceStaffOnboarding onboarding = SeedOriginalState(dbContext); + await dbContext.SaveChangesAsync(); + + Assert.True(onboarding.ObserveInvitationAccepted( + Now.AddMinutes(1)).IsSuccess); + await dbContext.SaveChangesAsync(); + + Assert.True(onboarding.MarkStaffReady( + StaffMemberId, + Guid.NewGuid(), + Guid.NewGuid(), + Now.AddMinutes(2)).IsSuccess); + await dbContext.SaveChangesAsync(); + + Assert.True(onboarding.Complete( + Now.AddMinutes(3)).IsSuccess); await dbContext.SaveChangesAsync(); } @@ -286,7 +301,7 @@ private static async Task ResetToOriginalStateAsync( await SeedOriginalStateAsync(connectionString); } - private static void SeedOriginalState( + private static WorkspaceStaffOnboarding SeedOriginalState( WorkspacesDbContext dbContext) { WorkspaceStaffOnboarding onboarding = @@ -305,14 +320,6 @@ private static void SeedOriginalState( jobTitle: null, department: null, Now).Value; - Assert.True(onboarding.ObserveInvitationAccepted( - Now.AddMinutes(1)).IsSuccess); - Assert.True(onboarding.MarkStaffReady( - StaffMemberId, - Now.AddMinutes(2)).IsSuccess); - Assert.True(onboarding.Complete( - Now.AddMinutes(3)).IsSuccess); - WorkspaceStaffAccessProcess anchor = WorkspaceStaffAccessProcess.Create( AnchorProcessId, @@ -344,6 +351,7 @@ private static void SeedOriginalState( Now.AddMinutes(1)).IsSuccess); dbContext.AddRange(onboarding, anchor, plan); + return onboarding; } private static async Task AssertOriginalStateAsync( diff --git a/tests/Integration.Tests/Workspaces/WorkspaceStaffHistoricalNoProvisionTransactionIntegrationTests.cs b/tests/Integration.Tests/Workspaces/WorkspaceStaffHistoricalNoProvisionTransactionIntegrationTests.cs new file mode 100644 index 00000000..300b0ce6 --- /dev/null +++ b/tests/Integration.Tests/Workspaces/WorkspaceStaffHistoricalNoProvisionTransactionIntegrationTests.cs @@ -0,0 +1,523 @@ +namespace Integration.Tests; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Persistence; +using Gma.Framework.Application.Events.Infrastructure; +using Gma.Framework.Cqrs; +using Gma.Framework.Cqrs.Infrastructure; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; +using Gma.Framework.Scoping; +using Gma.Framework.Scoping.Infrastructure; +using Gma.Modules.Organizations.Contracts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class + WorkspaceStaffHistoricalNoProvisionTransactionIntegrationTests +{ + private const string TenantId = + "ac000000-0000-0000-0000-000000000001"; + private static readonly DateTimeOffset Now = new( + 2026, + 8, + 11, + 19, + 0, + 0, + TimeSpan.Zero); + private static readonly string EvidenceSha256 = new('a', 64); + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Dispatcher_commits_atomically_and_rolls_back_after_handler_faults() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspace_historical_review_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + + Guid committedApplicationId = Guid.NewGuid(); + Guid committedSourceId = Guid.NewGuid(); + DispatchProbe committedProbe = new(); + await using (ServiceProvider committed = CreateProvider( + connectionString, + committedSourceId, + Guid.NewGuid(), + DispatchFault.None, + committedProbe)) + { + await EnsureSchemaAsync(committed).ConfigureAwait(false); + SeedCoordinate seed = await SeedAsync( + committed, + committedApplicationId, + committedSourceId, + "subject:committed") + .ConfigureAwait(false); + ReviewWorkspaceStaffHistoricalNoProvisionCommand command = + CreateCommand(seed, Guid.NewGuid()); + + Result + first = await SendAsync(committed, command) + .ConfigureAwait(false); + Assert.True(first.IsSuccess, first.Error.Code); + Assert.False(first.Value.AlreadyReviewed); + await AssertCommittedAsync( + committed, + seed, + first.Value) + .ConfigureAwait(false); + + Result + replay = await SendAsync(committed, command) + .ConfigureAwait(false); + Assert.True(replay.IsSuccess, replay.Error.Code); + Assert.True(replay.Value.AlreadyReviewed); + Assert.Equal(first.Value.ReceiptId, replay.Value.ReceiptId); + Assert.Equal(first.Value.CanonicalSha256, replay.Value.CanonicalSha256); + Assert.Equal(1, committedProbe.StaffOutcomeCalls); + Assert.Equal(1, committedProbe.StaffInspectionCalls); + Assert.Equal(2, committedProbe.OrganizationSnapshotCalls); + Assert.Equal(1, committedProbe.OrganizationExportCalls); + } + + await ProveRollbackAsync( + connectionString, + DispatchFault.ResultFailure) + .ConfigureAwait(false); + await ProveRollbackAsync( + connectionString, + DispatchFault.Exception) + .ConfigureAwait(false); + } + + private static async Task ProveRollbackAsync( + string connectionString, + DispatchFault fault) + { + Guid applicationId = Guid.NewGuid(); + Guid sourceId = Guid.NewGuid(); + DispatchProbe probe = new(); + await using ServiceProvider services = CreateProvider( + connectionString, + sourceId, + Guid.NewGuid(), + fault, + probe); + SeedCoordinate seed = await SeedAsync( + services, + applicationId, + sourceId, + "subject:rollback-" + fault) + .ConfigureAwait(false); + ReviewWorkspaceStaffHistoricalNoProvisionCommand command = + CreateCommand(seed, Guid.NewGuid()); + + if (fault == DispatchFault.ResultFailure) + { + Result + result = await SendAsync(services, command) + .ConfigureAwait(false); + Assert.Equal(InjectedFailureBehavior.Error, result.Error); + } + else + { + await Assert.ThrowsAsync(() => + SendAsync(services, command)).ConfigureAwait(false); + } + + Assert.Equal(1, probe.AfterHandlerCalls); + Assert.Equal(1, probe.StaffOutcomeCalls); + Assert.Equal(1, probe.StaffInspectionCalls); + Assert.Equal(2, probe.OrganizationSnapshotCalls); + Assert.Equal(1, probe.OrganizationExportCalls); + await using AsyncServiceScope verification = + services.CreateAsyncScope(); + verification.ServiceProvider + .GetRequiredService() + .SetScope(TenantId); + WorkspacesDbContext database = verification.ServiceProvider + .GetRequiredService(); + WorkspaceStaffOnboarding application = await database + .StaffOnboardingApplications + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == applicationId) + .ConfigureAwait(false); + Assert.Equal(seed.Version, application.Version); + Assert.Equal(seed.Status, application.Status); + Assert.Equal(seed.SubjectId, application.SubjectId); + Assert.False(application.HasIdentityAnchorState); + Assert.False(await database.StaffHistoricalNoProvisionReceipts + .AsNoTracking() + .AnyAsync(receipt => receipt.ApplicationId == applicationId) + .ConfigureAwait(false)); + } + + private static async Task AssertCommittedAsync( + ServiceProvider services, + SeedCoordinate seed, + WorkspaceStaffHistoricalNoProvisionDispositionResult result) + { + await using AsyncServiceScope verification = + services.CreateAsyncScope(); + verification.ServiceProvider + .GetRequiredService() + .SetScope(TenantId); + WorkspacesDbContext database = verification.ServiceProvider + .GetRequiredService(); + WorkspaceStaffOnboarding application = await database + .StaffOnboardingApplications + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == seed.ApplicationId) + .ConfigureAwait(false); + WorkspaceStaffHistoricalNoProvisionReceipt receipt = await database + .StaffHistoricalNoProvisionReceipts + .AsNoTracking() + .SingleAsync(candidate => + candidate.ApplicationId == seed.ApplicationId) + .ConfigureAwait(false); + + Assert.Equal(seed.Version + 1, application.Version); + Assert.Equal( + WorkspaceStaffOnboardingState.Superseded, + application.Status); + Assert.Equal(receipt.CreateSubjectPseudonym(), application.SubjectId); + Assert.Null(application.VerifiedAccountEmail); + Assert.Null(application.DisplayName); + Assert.False(application.HasIdentityAnchorState); + Assert.Equal(result.ReceiptId, receipt.Id); + Assert.Equal(result.CanonicalSha256, receipt.CanonicalSha256); + Assert.True(receipt.HasValidCanonicalProof()); + Assert.Empty(await database.OutboxMessages.AsNoTracking() + .ToArrayAsync() + .ConfigureAwait(false)); + } + + private static async Task SeedAsync( + ServiceProvider services, + Guid applicationId, + Guid sourceId, + string subjectId) + { + await using AsyncServiceScope scope = services.CreateAsyncScope(); + scope.ServiceProvider.GetRequiredService() + .SetScope(TenantId); + WorkspacesDbContext database = scope.ServiceProvider + .GetRequiredService(); + WorkspaceStaffOnboarding application = + WorkspaceStaffOnboarding.Create( + applicationId, + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + sourceId, + subjectId, + applicationId.ToString("N") + "@example.test", + "Historical Review", + legalName: "Historical Review", + workEmail: null, + workPhone: null, + employeeNumber: null, + jobTitle: null, + department: null, + Now.AddHours(-1)).Value; + database.StaffOnboardingApplications.Add(application); + await database.SaveChangesAsync().ConfigureAwait(false); + return new( + application.Id, + application.SourceId, + application.SubjectId, + application.Version, + application.Status); + } + + private static ReviewWorkspaceStaffHistoricalNoProvisionCommand + CreateCommand( + SeedCoordinate seed, + Guid operationId) => new( + operationId, + seed.ApplicationId, + seed.Version, + seed.Status, + ExpectedOrganizationsScopeRevision: 4, + ExpectedOrganizationsSourceVersion: 7, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationRevoked, + Guid.NewGuid(), + EvidenceSha256, + "operator:historical-review"); + + private static async Task> SendAsync( + ServiceProvider services, + ReviewWorkspaceStaffHistoricalNoProvisionCommand command) + { + await using AsyncServiceScope scope = services.CreateAsyncScope(); + scope.ServiceProvider.GetRequiredService() + .SetScope(TenantId); + return await scope.ServiceProvider + .GetRequiredService() + .SendAsync(command, CancellationToken.None) + .ConfigureAwait(false); + } + + private static async Task EnsureSchemaAsync(ServiceProvider services) + { + await using AsyncServiceScope scope = services.CreateAsyncScope(); + scope.ServiceProvider.GetRequiredService() + .SetScope(TenantId); + await scope.ServiceProvider.GetRequiredService() + .Database.EnsureCreatedAsync() + .ConfigureAwait(false); + } + + private static ServiceProvider CreateProvider( + string connectionString, + Guid sourceId, + Guid receiptId, + DispatchFault fault, + DispatchProbe probe) + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration["Persistence:Provider"] = "PostgreSql"; + builder.Configuration["ConnectionStrings:PostgreSql"] = + connectionString; + builder.Configuration["Scoping:Enabled"] = "true"; + builder.AddScopingInfrastructure(); + builder.AddApplicationEventsInfrastructure(); + builder.AddCqrsInfrastructure(); + builder.Services.AddWorkspacesApplication( + builder.Configuration, + "global"); + builder.AddWorkspacesPersistence(); + + TestStaffEvidence staff = new(probe); + builder.Services.AddSingleton< + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader>(staff); + builder.Services.AddSingleton( + staff); + builder.Services.AddSingleton( + new TestOrganizations(sourceId, probe)); + builder.Services.Replace(ServiceDescriptor.Singleton( + new FixedIdGenerator(receiptId))); + builder.Services.Replace(ServiceDescriptor.Singleton( + new FixedClock())); + builder.Services.AddSingleton(probe); + if (fault == DispatchFault.ResultFailure) + { + builder.Services.AddScoped, + InjectedFailureBehavior>(); + } + else if (fault == DispatchFault.Exception) + { + builder.Services.AddScoped, + InjectedExceptionBehavior>(); + } + + return builder.Services.BuildServiceProvider( + new ServiceProviderOptions { ValidateScopes = true }); + } + + private sealed class InjectedFailureBehavior(DispatchProbe probe) + : ICommandPipelineBehavior< + ReviewWorkspaceStaffHistoricalNoProvisionCommand, + WorkspaceStaffHistoricalNoProvisionDispositionResult> + { + public static readonly Error Error = new( + "Workspaces.InjectedHistoricalReviewFailure", + "The provider proof injects a failure after the handler."); + + public async Task> + HandleAsync( + ReviewWorkspaceStaffHistoricalNoProvisionCommand command, + CommandNext< + WorkspaceStaffHistoricalNoProvisionDispositionResult> + next, + CancellationToken cancellationToken) + { + Result + result = await next().ConfigureAwait(false); + Assert.True(result.IsSuccess, result.Error.Code); + probe.AfterHandlerCalls++; + return Result.Failure< + WorkspaceStaffHistoricalNoProvisionDispositionResult>(Error); + } + } + + private sealed class InjectedExceptionBehavior(DispatchProbe probe) + : ICommandPipelineBehavior< + ReviewWorkspaceStaffHistoricalNoProvisionCommand, + WorkspaceStaffHistoricalNoProvisionDispositionResult> + { + public async Task> + HandleAsync( + ReviewWorkspaceStaffHistoricalNoProvisionCommand command, + CommandNext< + WorkspaceStaffHistoricalNoProvisionDispositionResult> + next, + CancellationToken cancellationToken) + { + Result + result = await next().ConfigureAwait(false); + Assert.True(result.IsSuccess, result.Error.Code); + probe.AfterHandlerCalls++; + throw new InjectedAfterHandlerException(); + } + } + + private sealed class TestStaffEvidence(DispatchProbe probe) + : IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader, + IStaffIdentityProvisioningAnchorCutover + { + public Task> ReadAsync( + IReadOnlyList< + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest> + requests, + CancellationToken cancellationToken = default) + { + probe.StaffOutcomeCalls++; + return Task.FromResult>( + requests.Select(request => new + StaffWorkspaceOnboardingIdentityAnchorOutcome( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Absent, + StaffMemberId: null, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Unknown, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Unknown, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + ResolutionEventId: null)) + .ToArray()); + } + + public Task InspectAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken = default) + { + probe.StaffInspectionCalls++; + StaffIdentityProvisioningAnchorCandidate candidate = + Assert.Single(candidates); + return Task.FromResult(new StaffIdentityProvisioningAnchorInspection( + IsSuccess: true, + [new StaffIdentityProvisioningAnchorCandidateInspection( + candidate.SourceKind, + candidate.SourceId, + StaffIdentityProvisioningAnchorCutoverDisposition + .Ambiguous)], + ErrorCode: null)); + } + + public Task ApplyAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Apply must not be called."); + } + + private sealed class TestOrganizations( + Guid sourceId, + DispatchProbe probe) + : IOrganizationScopeLifecycle + { + public Task GetSnapshotAsync( + Guid organizationId, + CancellationToken cancellationToken) + { + probe.OrganizationSnapshotCalls++; + return Task.FromResult(new OrganizationScopeSnapshot( + OrganizationScopeStatus.Open, + Revision: 4)); + } + + public Task ExportAsync( + OrganizationScopeExportRequest request, + CancellationToken cancellationToken) + { + probe.OrganizationExportCalls++; + OrganizationScopeInvitationExportRecord record = new( + sourceId, + Guid.Parse(TenantId), + "subject:inviter", + RecipientEmail: null, + TokenVersion: 1, + Now.AddDays(-1), + OrganizationInvitationStatus.Revoked, + AcceptedSubjectId: null, + AcceptedMembershipId: null, + AcceptedAtUtc: null, + Version: 7, + CreatedBy: "operator", + CreatedAtUtc: Now.AddYears(-1), + LastChangedBy: "operator", + LastChangedAtUtc: Now.AddDays(-1)); + return Task.FromResult(new OrganizationScopeExportPage( + OrganizationScopeExportStatus.Completed, + ScopeRevision: 4, + OrganizationScopeExportStore.Invitations, + [record], + "id:" + sourceId.ToString("D"), + HasMore: false)); + } + + public Task DestroyBatchAsync( + OrganizationScopeDestroyRequest request, + CancellationToken cancellationToken) => + throw new InvalidOperationException("Destroy must not be called."); + } + + private sealed class FixedIdGenerator(Guid receiptId) : IIdGenerator + { + public Guid NewId() => receiptId; + } + + private sealed class FixedClock : ISystemClock + { + public DateTimeOffset UtcNow => Now; + } + + private sealed class DispatchProbe + { + public int AfterHandlerCalls { get; set; } + public int StaffOutcomeCalls { get; set; } + public int StaffInspectionCalls { get; set; } + public int OrganizationSnapshotCalls { get; set; } + public int OrganizationExportCalls { get; set; } + } + + private sealed class InjectedAfterHandlerException : Exception; + + private sealed record SeedCoordinate( + Guid ApplicationId, + Guid SourceId, + string SubjectId, + long Version, + WorkspaceStaffOnboardingState Status); + + private enum DispatchFault + { + None = 0, + ResultFailure = 1, + Exception = 2 + } +} diff --git a/tests/Integration.Tests/Workspaces/WorkspaceStaffIdentityAnchorCutoverIntegrationTests.cs b/tests/Integration.Tests/Workspaces/WorkspaceStaffIdentityAnchorCutoverIntegrationTests.cs new file mode 100644 index 00000000..85d77a3b --- /dev/null +++ b/tests/Integration.Tests/Workspaces/WorkspaceStaffIdentityAnchorCutoverIntegrationTests.cs @@ -0,0 +1,859 @@ +namespace Integration.Tests; + +using BunkFy.Modules.Staff.Application; +using BunkFy.Modules.Staff.Application.Commands; +using BunkFy.Modules.Staff.Application.Ports; +using BunkFy.Modules.Staff.Application.Queries; +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Staff.Domain.Aggregates; +using BunkFy.Modules.Staff.Persistence; +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Queries; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Persistence; +using Gma.Framework.Application.Events.Infrastructure; +using Gma.Framework.Cqrs; +using Gma.Framework.Cqrs.Infrastructure; +using Gma.Framework.Messaging.Infrastructure; +using Gma.Framework.Results; +using Gma.Framework.Scoping; +using Gma.Framework.Scoping.Infrastructure; +using Gma.Modules.Organizations.Contracts; +using Integration.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class WorkspaceStaffIdentityAnchorCutoverIntegrationTests +{ + private const string TenantId = + "b7000000-0000-0000-0000-000000000001"; + private const string EvidenceSha256 = + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + private static readonly DateTimeOffset Now = new( + 2026, + 8, + 11, + 12, + 0, + 0, + TimeSpan.Zero); + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Nonempty_reconcile_releases_Workspaces_lock_before_real_Staff_apply() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspace_anchor_cutover_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + await using ServiceProvider services = CreateProvider( + postgreSql.GetConnectionString()); + await MigrateAsync(services).ConfigureAwait(false); + Guid staffMemberId = Guid.NewGuid(); + Guid applicationId = Guid.NewGuid(); + await SeedCompletedApplicationAsync( + services, + staffMemberId, + applicationId).ConfigureAwait(false); + + WorkspaceStaffIdentityAnchorOwnerManifest manifest = EmptyManifest(); + Result before = + await QueryAsync( + services, + TenantId, + new GetWorkspaceStaffIdentityAnchorCutoverStatusQuery( + manifest)).ConfigureAwait(false); + Assert.True(before.IsSuccess, before.Error.Code); + Assert.Equal(1, before.Value.SeedableWorkspaceCount); + + Result reconciled = + await SendAsync( + services, + TenantId, + new ReconcileWorkspaceStaffIdentityAnchorsCommand( + before.Value.SourceEvidenceSha256, + before.Value.AnchorStateSha256, + manifest, + Assert.IsType( + before.Value.OwnerManifestSha256), + BatchSize: 1)) + .WaitAsync(TimeSpan.FromSeconds(20)) + .ConfigureAwait(false); + + Assert.True(reconciled.IsSuccess, reconciled.Error.Code); + Assert.Equal( + WorkspaceStaffIdentityAnchorReconcileOutcome.AppliedAndVerified, + reconciled.Value.Outcome); + Assert.Equal(1, reconciled.Value.AppliedCount); + Assert.False(reconciled.Value.MustRerunStatus); + Assert.NotNull(reconciled.Value.Status); + Assert.True(reconciled.Value.Status!.IsReady); + Assert.Equal(1, reconciled.Value.Status.AlreadyAnchoredCount); + + using IServiceScope verificationScope = CreateTenantScope( + services, + TenantId); + StaffDbContext staff = verificationScope.ServiceProvider + .GetRequiredService(); + Assert.Equal( + 1, + await staff.IdentityProvisioningAnchors.AsNoTracking() + .CountAsync() + .ConfigureAwait(false)); + + Result stale = + await SendAsync( + services, + TenantId, + new ReconcileWorkspaceStaffIdentityAnchorsCommand( + before.Value.SourceEvidenceSha256, + before.Value.AnchorStateSha256, + manifest, + Assert.IsType(before.Value.OwnerManifestSha256), + BatchSize: 1)).ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverErrors.AnchorStateChanged, + stale.Error); + + Result noncanonical = + await QueryAsync( + services, + TenantId.ToUpperInvariant(), + new GetWorkspaceStaffIdentityAnchorCutoverStatusQuery( + manifest)).ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverErrors.TenantRequired, + noncanonical.Error); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Lost_response_after_real_Staff_commit_is_unknown_and_fresh_state_is_exact() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspace_anchor_lost_response_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + StaffCutoverProbe probe = new() { ThrowAfterSuccessfulApply = true }; + await using ServiceProvider services = CreateProvider( + postgreSql.GetConnectionString(), + probe); + await MigrateAsync(services).ConfigureAwait(false); + await SeedCompletedApplicationAsync( + services, + Guid.NewGuid(), + Guid.NewGuid()).ConfigureAwait(false); + await SeedCompletedApplicationAsync( + services, + Guid.NewGuid(), + Guid.NewGuid()).ConfigureAwait(false); + + WorkspaceStaffIdentityAnchorOwnerManifest manifest = EmptyManifest(); + Result before = + await QueryAsync( + services, + TenantId, + new GetWorkspaceStaffIdentityAnchorCutoverStatusQuery( + manifest)).ConfigureAwait(false); + Assert.True(before.IsSuccess, before.Error.Code); + Assert.Equal(2, before.Value.SeedableWorkspaceCount); + + Result reconciled = + await SendAsync( + services, + TenantId, + new ReconcileWorkspaceStaffIdentityAnchorsCommand( + before.Value.SourceEvidenceSha256, + before.Value.AnchorStateSha256, + manifest, + Assert.IsType( + before.Value.OwnerManifestSha256), + BatchSize: 2)) + .WaitAsync(TimeSpan.FromSeconds(20)) + .ConfigureAwait(false); + + Assert.True(reconciled.IsSuccess, reconciled.Error.Code); + Assert.Equal( + WorkspaceStaffIdentityAnchorReconcileOutcome.ApplyOutcomeUnknown, + reconciled.Value.Outcome); + Assert.Null(reconciled.Value.AppliedCount); + Assert.True(reconciled.Value.MustRerunStatus); + Assert.Equal( + before.Value.SourceEvidenceSha256, + reconciled.Value.AcceptedSourceEvidenceSha256); + Assert.Equal( + before.Value.AnchorStateSha256, + reconciled.Value.AcceptedAnchorStateSha256); + Assert.Equal( + before.Value.OwnerManifestSha256, + reconciled.Value.AcceptedOwnerManifestSha256); + Assert.NotNull(reconciled.Value.Status); + Assert.True(reconciled.Value.Status!.IsReady); + Assert.Equal(2, reconciled.Value.Status.AlreadyAnchoredCount); + Assert.Equal(0, reconciled.Value.Status.SeedableWorkspaceCount); + Assert.Equal(1, probe.ApplyCalls); + + using (IServiceScope verificationScope = CreateTenantScope( + services, + TenantId)) + { + StaffDbContext staff = verificationScope.ServiceProvider + .GetRequiredService(); + Assert.Equal( + 2, + await staff.IdentityProvisioningAnchors.AsNoTracking() + .CountAsync() + .ConfigureAwait(false)); + } + + Result staleRetry = + await SendAsync( + services, + TenantId, + new ReconcileWorkspaceStaffIdentityAnchorsCommand( + before.Value.SourceEvidenceSha256, + before.Value.AnchorStateSha256, + manifest, + Assert.IsType(before.Value.OwnerManifestSha256), + BatchSize: 2)).ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffIdentityAnchorCutoverErrors.AnchorStateChanged, + staleRetry.Error); + Assert.Equal(1, probe.ApplyCalls); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Cancelled_preflight_rolls_back_exclusive_lock_before_shared_admission() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspace_anchor_cancel_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + TestOrganizationAuthority organizations = new(); + await using ServiceProvider services = CreateProvider( + postgreSql.GetConnectionString(), + organizationAuthority: organizations); + await MigrateAsync(services).ConfigureAwait(false); + await SeedCompletedApplicationAsync( + services, + Guid.NewGuid(), + Guid.NewGuid()).ConfigureAwait(false); + WorkspaceStaffIdentityAnchorOwnerManifest manifest = EmptyManifest(); + Result before = + await QueryAsync( + services, + TenantId, + new GetWorkspaceStaffIdentityAnchorCutoverStatusQuery( + manifest)).ConfigureAwait(false); + Assert.True(before.IsSuccess, before.Error.Code); + + organizations.BlockNextSnapshot(); + using IServiceScope reconcileScope = CreateTenantScope( + services, + TenantId); + using CancellationTokenSource cancellation = new(); + Task> reconcile = + reconcileScope.ServiceProvider + .GetRequiredService() + .SendAsync( + new ReconcileWorkspaceStaffIdentityAnchorsCommand( + before.Value.SourceEvidenceSha256, + before.Value.AnchorStateSha256, + manifest, + Assert.IsType( + before.Value.OwnerManifestSha256), + BatchSize: 1), + cancellation.Token); + await organizations.SnapshotBlocked.Task + .WaitAsync(TimeSpan.FromSeconds(10)) + .ConfigureAwait(false); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(async () => + await reconcile.ConfigureAwait(false)) + .ConfigureAwait(false); + + using IServiceScope writerScope = CreateTenantScope( + services, + TenantId); + WorkspacesDbContext writer = writerScope.ServiceProvider + .GetRequiredService(); + WorkspaceStaffOnboarding submitted = CreateSubmittedApplication( + "after-cancel"); + writer.StaffOnboardingApplications.Add(submitted); + await writer.SaveChangesAsync() + .WaitAsync(TimeSpan.FromSeconds(10)) + .ConfigureAwait(false); + + using IServiceScope verificationScope = CreateTenantScope( + services, + TenantId); + StaffDbContext staff = verificationScope.ServiceProvider + .GetRequiredService(); + Assert.Empty(await staff.IdentityProvisioningAnchors + .AsNoTracking() + .ToArrayAsync() + .ConfigureAwait(false)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Fresh_verification_holds_exclusive_lock_across_status_and_exact_inspection() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspace_anchor_fresh_lock_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + + StaffCutoverProbe probe = new() { BlockInspectionCall = 4 }; + await using ServiceProvider services = CreateProvider( + connectionString, + probe); + await MigrateAsync(services).ConfigureAwait(false); + await SeedCompletedApplicationAsync( + services, + Guid.NewGuid(), + Guid.NewGuid()).ConfigureAwait(false); + WorkspaceStaffIdentityAnchorOwnerManifest manifest = EmptyManifest(); + Result before = + await QueryAsync( + services, + TenantId, + new GetWorkspaceStaffIdentityAnchorCutoverStatusQuery( + manifest)).ConfigureAwait(false); + Assert.True(before.IsSuccess, before.Error.Code); + + Task> reconcile = + SendAsync( + services, + TenantId, + new ReconcileWorkspaceStaffIdentityAnchorsCommand( + before.Value.SourceEvidenceSha256, + before.Value.AnchorStateSha256, + manifest, + Assert.IsType(before.Value.OwnerManifestSha256), + BatchSize: 1)); + await probe.InspectionBlocked.Task + .WaitAsync(TimeSpan.FromSeconds(15)) + .ConfigureAwait(false); + + Task writer = WriteSubmittedApplicationAsync( + services, + "during-fresh-verification"); + try + { + await WaitForSharedAdmissionWaitAsync(connectionString) + .ConfigureAwait(false); + Assert.False(writer.IsCompleted); + } + finally + { + probe.ReleaseInspection.TrySetResult(); + } + + Result result = + await reconcile.WaitAsync(TimeSpan.FromSeconds(15)) + .ConfigureAwait(false); + await writer.WaitAsync(TimeSpan.FromSeconds(15)) + .ConfigureAwait(false); + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal( + WorkspaceStaffIdentityAnchorReconcileOutcome.AppliedAndVerified, + result.Value.Outcome); + Assert.Equal(4, probe.InspectCalls); + Assert.Equal(1, probe.ApplyCalls); + + Result after = + await QueryAsync( + services, + TenantId, + new GetWorkspaceStaffIdentityAnchorCutoverStatusQuery( + manifest)).ConfigureAwait(false); + Assert.True(after.IsSuccess, after.Error.Code); + Assert.NotEqual( + result.Value.AcceptedSourceEvidenceSha256, + after.Value.SourceEvidenceSha256); + Assert.Equal(2, after.Value.WorkspaceSourceCount); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Source_drift_after_preflight_before_real_apply_returns_unknown() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspace_anchor_drift_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + StaffCutoverProbe probe = new() + { + CommitWorkspaceDriftBeforeApply = true + }; + await using ServiceProvider services = CreateProvider( + postgreSql.GetConnectionString(), + probe); + await MigrateAsync(services).ConfigureAwait(false); + await SeedCompletedApplicationAsync( + services, + Guid.NewGuid(), + Guid.NewGuid()).ConfigureAwait(false); + WorkspaceStaffIdentityAnchorOwnerManifest manifest = EmptyManifest(); + Result before = + await QueryAsync( + services, + TenantId, + new GetWorkspaceStaffIdentityAnchorCutoverStatusQuery( + manifest)).ConfigureAwait(false); + Assert.True(before.IsSuccess, before.Error.Code); + + Result result = + await SendAsync( + services, + TenantId, + new ReconcileWorkspaceStaffIdentityAnchorsCommand( + before.Value.SourceEvidenceSha256, + before.Value.AnchorStateSha256, + manifest, + Assert.IsType( + before.Value.OwnerManifestSha256), + BatchSize: 1)) + .WaitAsync(TimeSpan.FromSeconds(20)) + .ConfigureAwait(false); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal( + WorkspaceStaffIdentityAnchorReconcileOutcome.ApplyOutcomeUnknown, + result.Value.Outcome); + Assert.Equal(1, result.Value.AppliedCount); + Assert.True(result.Value.MustRerunStatus); + Assert.NotNull(result.Value.Status); + Assert.NotEqual( + result.Value.AcceptedSourceEvidenceSha256, + result.Value.Status!.SourceEvidenceSha256); + Assert.Equal(2, result.Value.Status.WorkspaceSourceCount); + Assert.Equal(1, probe.ApplyCalls); + Assert.Equal(1, probe.DriftWrites); + } + + private static async Task WriteSubmittedApplicationAsync( + ServiceProvider services, + string label) + { + using IServiceScope scope = CreateTenantScope(services, TenantId); + WorkspacesDbContext dbContext = scope.ServiceProvider + .GetRequiredService(); + dbContext.StaffOnboardingApplications.Add( + CreateSubmittedApplication(label)); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + + private static WorkspaceStaffOnboarding CreateSubmittedApplication( + string label) => + WorkspaceStaffOnboarding.Create( + Guid.NewGuid(), + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + Guid.NewGuid(), + "account-" + label, + label + "@example.test", + label, + legalName: null, + workEmail: null, + workPhone: null, + employeeNumber: null, + jobTitle: null, + department: null, + Now.AddHours(1)).Value; + + private static async Task WaitForSharedAdmissionWaitAsync( + string connectionString) + { + await using NpgsqlConnection observer = new(connectionString); + await observer.OpenAsync().ConfigureAwait(false); + DateTimeOffset deadline = DateTimeOffset.UtcNow.AddSeconds(15); + while (DateTimeOffset.UtcNow < deadline) + { + await using NpgsqlCommand command = new( + """ + SELECT EXISTS ( + SELECT 1 + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND state = 'active' + AND wait_event_type = 'Lock' + AND query ILIKE + '%pg_advisory_xact_lock_shared%'); + """, + observer); + if ((bool)(await command.ExecuteScalarAsync() + .ConfigureAwait(false))!) + { + return; + } + + await Task.Delay(TimeSpan.FromMilliseconds(25)) + .ConfigureAwait(false); + } + + throw new TimeoutException( + "The Workspaces writer did not wait for shared tenant admission."); + } + + private static async Task SeedCompletedApplicationAsync( + ServiceProvider services, + Guid staffMemberId, + Guid applicationId) + { + using (IServiceScope staffScope = CreateTenantScope( + services, + TenantId)) + { + StaffDbContext dbContext = staffScope.ServiceProvider + .GetRequiredService(); + string suffix = staffMemberId.ToString("N"); + string subjectId = "account-cutover-" + suffix; + StaffMember member = StaffMember.Create( + staffMemberId, + TenantId, + "Cutover Staff", + legalName: null, + workEmail: $"cutover-{suffix}@example.test", + workPhone: null, + employeeNumber: null, + jobTitle: null, + department: null, + authSubjectId: subjectId, + actorId: "system:integration-test", + eventId: Guid.NewGuid(), + Now).Value; + await staffScope.ServiceProvider + .GetRequiredService() + .AddAsync(member, CancellationToken.None) + .ConfigureAwait(false); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + + using IServiceScope workspaceScope = CreateTenantScope( + services, + TenantId); + WorkspacesDbContext workspaces = workspaceScope.ServiceProvider + .GetRequiredService(); + string workspaceSuffix = staffMemberId.ToString("N"); + string workspaceSubjectId = "account-cutover-" + workspaceSuffix; + WorkspaceStaffOnboarding onboarding = WorkspaceStaffOnboarding.Create( + applicationId, + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + Guid.NewGuid(), + workspaceSubjectId, + $"cutover-{workspaceSuffix}@example.test", + "Cutover Staff", + legalName: null, + workEmail: null, + workPhone: null, + employeeNumber: null, + jobTitle: null, + department: null, + Now).Value; + workspaces.StaffOnboardingApplications.Add(onboarding); + await workspaces.SaveChangesAsync().ConfigureAwait(false); + + Assert.True(onboarding.ObserveInvitationAccepted( + Now.AddMinutes(1)).IsSuccess); + await workspaces.SaveChangesAsync().ConfigureAwait(false); + + Assert.True(onboarding.MarkStaffReady( + staffMemberId, + Guid.NewGuid(), + Guid.NewGuid(), + Now.AddMinutes(2)).IsSuccess); + await workspaces.SaveChangesAsync().ConfigureAwait(false); + + Assert.True(onboarding.Complete(Now.AddMinutes(3)).IsSuccess); + await workspaces.SaveChangesAsync().ConfigureAwait(false); + } + + private static WorkspaceStaffIdentityAnchorOwnerManifest EmptyManifest() => + new( + ContractVersion: 1, + TenantId, + ReviewedAtUtc: Now, + new WorkspaceStaffIdentityAnchorHistoricalEvidence( + WorkspaceStaffIdentityAnchorHistoricalEvidenceKind + .ReviewedNoHistoricalOwnerSources, + EvidenceSha256), + Bindings: []); + + private static async Task> QueryAsync( + ServiceProvider services, + string tenantId, + IQuery query) + { + using IServiceScope scope = CreateTenantScope(services, tenantId); + return await scope.ServiceProvider + .GetRequiredService() + .QueryAsync(query, CancellationToken.None) + .ConfigureAwait(false); + } + + private static async Task> SendAsync( + ServiceProvider services, + string tenantId, + ICommand command) + { + using IServiceScope scope = CreateTenantScope(services, tenantId); + return await scope.ServiceProvider + .GetRequiredService() + .SendAsync(command, CancellationToken.None) + .ConfigureAwait(false); + } + + private static IServiceScope CreateTenantScope( + ServiceProvider services, + string tenantId) + { + IServiceScope scope = services.CreateScope(); + scope.ServiceProvider.GetRequiredService() + .SetScope(tenantId); + return scope; + } + + private static async Task MigrateAsync(ServiceProvider services) + { + using IServiceScope scope = CreateTenantScope(services, TenantId); + await scope.ServiceProvider.GetRequiredService() + .Database.GetService() + .MigrateAsync() + .ConfigureAwait(false); + await scope.ServiceProvider.GetRequiredService() + .Database.GetService() + .MigrateAsync() + .ConfigureAwait(false); + } + + private static ServiceProvider CreateProvider( + string connectionString, + StaffCutoverProbe? probe = null, + TestOrganizationAuthority? organizationAuthority = null) + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration["Persistence:Provider"] = "PostgreSql"; + builder.Configuration["ConnectionStrings:PostgreSql"] = + connectionString; + builder.Configuration["Scoping:Enabled"] = "true"; + builder.AddScopingInfrastructure(); + builder.AddApplicationEventsInfrastructure(); + builder.AddCqrsInfrastructure(); + builder.AddMessagingInfrastructure(); + TestOrganizationAuthority organizations = + organizationAuthority ?? new TestOrganizationAuthority(); + builder.Services.AddSingleton( + organizations); + builder.Services.AddSingleton( + organizations); + builder.Services.AddStaffApplication(); + builder.AddStaffPersistence(); + if (probe is not null) + { + builder.Services.AddSingleton(probe); + builder.Services.Replace(ServiceDescriptor.Scoped< + IStaffIdentityProvisioningAnchorCutover, + DispatcherStaffCutover>()); + } + + builder.Services.AddWorkspacesApplication( + builder.Configuration, + "global"); + builder.AddWorkspacesPersistence(); + return builder.Services.BuildServiceProvider( + new ServiceProviderOptions { ValidateScopes = true }); + } + + private sealed class DispatcherStaffCutover( + IRequestDispatcher dispatcher, + StaffCutoverProbe probe, + IServiceScopeFactory scopeFactory) + : IStaffIdentityProvisioningAnchorCutover + { + public async Task + InspectAsync( + IReadOnlyList + candidates, + CancellationToken cancellationToken = default) + { + int inspectionCall = Interlocked.Increment( + ref probe.InspectCalls); + Result> result = + await dispatcher.QueryAsync( + new InspectStaffIdentityProvisioningAnchorsQuery( + candidates), + cancellationToken).ConfigureAwait(false); + if (inspectionCall == probe.BlockInspectionCall) + { + probe.InspectionBlocked.TrySetResult(); + await probe.ReleaseInspection.Task + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + } + + return result.IsSuccess + ? new(true, result.Value, ErrorCode: null) + : new(false, [], result.Error.Code); + } + + public async Task + ApplyAsync( + IReadOnlyList + candidates, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref probe.ApplyCalls); + if (probe.CommitWorkspaceDriftBeforeApply && + Interlocked.Exchange(ref probe.DriftWrites, 1) == 0) + { + using IServiceScope scope = scopeFactory.CreateScope(); + scope.ServiceProvider + .GetRequiredService() + .SetScope(TenantId); + WorkspacesDbContext workspaces = scope.ServiceProvider + .GetRequiredService(); + workspaces.StaffOnboardingApplications.Add( + CreateSubmittedApplication("before-apply-drift")); + await workspaces.SaveChangesAsync(cancellationToken) + .ConfigureAwait(false); + } + + Result result = + await dispatcher.SendAsync( + new ApplyStaffIdentityProvisioningAnchorsCommand( + candidates), + cancellationToken).ConfigureAwait(false); + if (result.IsSuccess && probe.ThrowAfterSuccessfulApply) + { + throw new LostStaffApplyResponseException(); + } + + return result.IsSuccess + ? new( + true, + result.Value.AppliedCount, + result.Value.AlreadyAnchoredCount, + ErrorCode: null) + : new(false, 0, 0, result.Error.Code); + } + } + + private sealed class StaffCutoverProbe + { + public bool ThrowAfterSuccessfulApply { get; init; } + public bool CommitWorkspaceDriftBeforeApply { get; init; } + public int BlockInspectionCall { get; init; } + public TaskCompletionSource InspectionBlocked { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReleaseInspection { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + public int ApplyCalls; + public int InspectCalls; + public int DriftWrites; + } + + private sealed class LostStaffApplyResponseException() + : Exception("The Staff apply response was lost after commit."); + + private sealed class TestOrganizationAuthority : + IOrganizationScopeLifecycle, + IOrganizationMembershipInspector + { + private int blockNextSnapshot; + + public TaskCompletionSource SnapshotBlocked { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public void BlockNextSnapshot() => + Interlocked.Exchange(ref this.blockNextSnapshot, 1); + + public async Task GetSnapshotAsync( + Guid organizationId, + CancellationToken cancellationToken) + { + if (Interlocked.Exchange(ref this.blockNextSnapshot, 0) == 1) + { + this.SnapshotBlocked.TrySetResult(); + await Task.Delay( + Timeout.InfiniteTimeSpan, + cancellationToken) + .ConfigureAwait(false); + } + + return new OrganizationScopeSnapshot( + OrganizationScopeStatus.Open, + Revision: 1); + } + + public Task ExportAsync( + OrganizationScopeExportRequest request, + CancellationToken cancellationToken) + { + IReadOnlyList records = + request.Store == OrganizationScopeExportStore.Organization + ? + [ + new OrganizationScopeOrganizationExportRecord( + request.OrganizationId, + "Cutover organization", + "cutover-organization", + OrganizationStatus.Active, + ActiveOwnerCount: 0, + Version: 1, + CreatedBy: "system:integration-test", + CreatedAtUtc: Now, + LastChangedBy: "system:integration-test", + LastChangedAtUtc: Now) + ] + : []; + return Task.FromResult(new OrganizationScopeExportPage( + OrganizationScopeExportStatus.Completed, + ScopeRevision: 1, + request.Store, + records, + NextCursor: null, + HasMore: false)); + } + + public Task FindAsync( + Guid organizationId, + Guid membershipId, + string subjectId, + CancellationToken cancellationToken = default) => + Task.FromResult(null); + + public Task DestroyBatchAsync( + OrganizationScopeDestroyRequest request, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } +} diff --git a/tests/Integration.Tests/Workspaces/WorkspaceStaffIdentityAnchorSweepTransactionIntegrationTests.cs b/tests/Integration.Tests/Workspaces/WorkspaceStaffIdentityAnchorSweepTransactionIntegrationTests.cs new file mode 100644 index 00000000..adb6e45c --- /dev/null +++ b/tests/Integration.Tests/Workspaces/WorkspaceStaffIdentityAnchorSweepTransactionIntegrationTests.cs @@ -0,0 +1,440 @@ +namespace Integration.Tests; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Application.Commands; +using BunkFy.Modules.Workspaces.Application.Handlers; +using BunkFy.Modules.Workspaces.Application.Models; +using BunkFy.Modules.Workspaces.Application.Ports; +using BunkFy.Modules.Workspaces.Application.Tasks; +using BunkFy.Modules.Workspaces.Contracts; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Persistence; +using BunkFy.Modules.Workspaces.Persistence.Repositories; +using Gma.Framework.Application.Events.Infrastructure; +using Gma.Framework.Cqrs; +using Gma.Framework.Cqrs.UnitOfWork; +using Gma.Framework.Messaging.Infrastructure; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; +using Gma.Framework.Scoping; +using Gma.Framework.Scoping.Infrastructure; +using Gma.Framework.Tasks; +using Gma.Framework.Tasks.Cqrs; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class + WorkspaceStaffIdentityAnchorSweepTransactionIntegrationTests +{ + private const string TenantId = + "b9000000-0000-0000-0000-000000000001"; + private const string SubjectId = "account-sweep-poison"; + private static readonly DateTimeOffset Now = + new(2026, 8, 11, 18, 0, 0, TimeSpan.Zero); + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Failed_candidate_scope_is_disposed_before_checkpoint_advance() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspace_anchor_sweep_scope_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + PoisonMutationProbe poison = new(); + await using ServiceProvider services = CreateProvider( + postgreSql.GetConnectionString(), + poison); + Guid applicationId = Guid.NewGuid(); + await CreateSchemaAndSeedAsync(services, applicationId) + .ConfigureAwait(false); + + await using (AsyncServiceScope executionScope = + services.CreateAsyncScope()) + { + IScopeContextAccessor scopeContext = executionScope + .ServiceProvider + .GetRequiredService(); + scopeContext.SetScope(TenantId); + try + { + TaskHandlerRegistration registration = services + .GetRequiredService() + .Find( + WorkspacesModuleMetadata.Name, + ReconcileWorkspaceStaffIdentityAnchorsPayload.TaskName, + ReconcileWorkspaceStaffIdentityAnchorsPayload + .PayloadVersion)!; + ITaskHandler + handler = Assert.IsType>( + executionScope.ServiceProvider + .GetRequiredService(registration.HandlerType), + exactMatch: false); + await handler.HandleAsync( + new(BatchSize: 10, MaxBatches: 1), + new TaskExecutionContext( + Guid.NewGuid(), + WorkspacesModuleMetadata.Name, + ReconcileWorkspaceStaffIdentityAnchorsPayload.TaskName, + ReconcileWorkspaceStaffIdentityAnchorsPayload + .WorkerGroup, + "worker-1", + "node-1", + attempt: 1, + scopeId: TenantId), + CancellationToken.None).ConfigureAwait(false); + } + finally + { + scopeContext.ClearScope(); + } + } + + await using AsyncServiceScope verificationScope = + services.CreateAsyncScope(); + IScopeContextAccessor verificationContext = verificationScope + .ServiceProvider + .GetRequiredService(); + verificationContext.SetScope(TenantId); + WorkspacesDbContext database = verificationScope.ServiceProvider + .GetRequiredService(); + WorkspaceStaffOnboarding application = await database + .StaffOnboardingApplications + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == applicationId) + .ConfigureAwait(false); + WorkspaceStaffIdentityAnchorSweepCheckpoint checkpoint = + await database.StaffIdentityAnchorSweepCheckpoints + .AsNoTracking() + .SingleAsync() + .ConfigureAwait(false); + + Assert.Equal( + WorkspaceStaffOnboardingState.Submitted, + application.Status); + Assert.Equal(1, application.Version); + Assert.False(await database.OutboxMessages.AsNoTracking() + .AnyAsync(message => message.Id == poison.OutboxId) + .ConfigureAwait(false)); + Assert.False(checkpoint.HasActiveCycle); + Assert.Equal(1, checkpoint.LastCompletedScannedCount); + Assert.Equal(1, checkpoint.LastCompletedConflictCount); + Assert.Equal(0, checkpoint.LastCompletedDeferredCount); + Assert.NotNull(checkpoint.LastCompletedCycleId); + Assert.NotNull(checkpoint.LastCompletedAtUtc); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Cycle_high_water_drains_prior_writer_and_fences_later_writer() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspace_anchor_sweep_barrier_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + PoisonMutationProbe poison = new(); + SweepBarrierProbe barrier = new(); + await using ServiceProvider services = CreateProvider( + postgreSql.GetConnectionString(), + poison, + barrier); + await CreateSchemaAsync(services).ConfigureAwait(false); + + Guid priorApplicationId = Guid.NewGuid(); + await using AsyncServiceScope priorWriter = services.CreateAsyncScope(); + SetScope(priorWriter.ServiceProvider); + WorkspacesDbContext priorDatabase = priorWriter.ServiceProvider + .GetRequiredService(); + await using Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction + priorTransaction = await priorDatabase.Database + .BeginTransactionAsync() + .ConfigureAwait(false); + WorkspaceStaffOnboarding prior = CreateApplication( + priorApplicationId, + "account-sweep-prior"); + priorDatabase.StaffOnboardingApplications.Add(prior); + await priorDatabase.SaveChangesAsync().ConfigureAwait(false); + Assert.True(prior.IdentityAnchorSweepOrdinal > 0); + + await using AsyncServiceScope sweepScope = services.CreateAsyncScope(); + SetScope(sweepScope.ServiceProvider); + IRequestDispatcher dispatcher = sweepScope.ServiceProvider + .GetRequiredService(); + Task> prepareTask = + dispatcher.SendAsync( + new PrepareWorkspaceStaffIdentityAnchorSweepPageCommand( + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + BatchSize: 10), + CancellationToken.None); + + await Task.Delay(TimeSpan.FromMilliseconds(250)) + .ConfigureAwait(false); + Assert.False(barrier.Acquired.Task.IsCompleted); + + await priorTransaction.CommitAsync().ConfigureAwait(false); + await barrier.Acquired.Task.WaitAsync(TimeSpan.FromSeconds(10)) + .ConfigureAwait(false); + + Guid laterApplicationId = Guid.NewGuid(); + await using AsyncServiceScope laterWriter = services.CreateAsyncScope(); + SetScope(laterWriter.ServiceProvider); + WorkspacesDbContext laterDatabase = laterWriter.ServiceProvider + .GetRequiredService(); + WorkspaceStaffOnboarding later = CreateApplication( + laterApplicationId, + "account-sweep-later"); + laterDatabase.StaffOnboardingApplications.Add(later); + Task laterSave = laterDatabase.SaveChangesAsync(); + await Task.Delay(TimeSpan.FromMilliseconds(250)) + .ConfigureAwait(false); + Assert.False(laterSave.IsCompleted); + + barrier.Release.TrySetResult(); + Result prepared = + await prepareTask.WaitAsync(TimeSpan.FromSeconds(10)) + .ConfigureAwait(false); + Assert.True(prepared.IsSuccess); + Assert.Equal( + prior.IdentityAnchorSweepOrdinal, + prepared.Value.UpperOrdinal); + WorkspaceStaffIdentityAnchorSweepCandidate selected = Assert.Single( + prepared.Value.Candidates); + Assert.Equal(priorApplicationId, selected.ApplicationId); + + await laterSave.WaitAsync(TimeSpan.FromSeconds(10)) + .ConfigureAwait(false); + Assert.True( + later.IdentityAnchorSweepOrdinal > + prepared.Value.UpperOrdinal); + Assert.DoesNotContain( + prepared.Value.Candidates, + candidate => candidate.ApplicationId == laterApplicationId); + } + + private static ServiceProvider CreateProvider( + string connectionString, + PoisonMutationProbe poison, + SweepBarrierProbe? barrier = null) + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration["Scoping:Enabled"] = "true"; + builder.AddScopingInfrastructure(); + builder.AddApplicationEventsInfrastructure(); + builder.AddTaskCqrs(); + builder.Services.AddDbContext(options => + options.UseNpgsql(connectionString)); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Scoped()); + builder.Services.AddScoped< + IWorkspaceStaffIdentityAnchorSweepRepository, + WorkspaceStaffIdentityAnchorSweepRepository>(); + builder.Services.AddScoped(); + builder.Services.AddScoped(provider => + barrier is null + ? provider.GetRequiredService() + : new PausingCrossGraphMutationLock( + provider.GetRequiredService< + WorkspaceCrossGraphMutationLock>(), + barrier)); + builder.Services.AddScoped, + PrepareWorkspaceStaffIdentityAnchorSweepPageCommandHandler>(); + builder.Services.AddScoped, + AdvanceWorkspaceStaffIdentityAnchorSweepCommandHandler>(); + builder.Services.AddScoped, + MutatingFailureCandidateHandler>(); + builder.Services.AddSingleton(poison); + builder.Services.AddSingleton(new FixedClock()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton< + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader, + UnresolvedOutcomeReader>(); + builder.Services.AddSingleton< + IStaffWorkspaceOnboardingIdentityAnchorResolutionRecorder, + UnexpectedResolutionRecorder>(); + builder.Services.AddWorkspacesTaskHandlers(); + return builder.Services.BuildServiceProvider( + new ServiceProviderOptions { ValidateScopes = true }); + } + + private static async Task CreateSchemaAndSeedAsync( + ServiceProvider services, + Guid applicationId) + { + await CreateSchemaAsync(services).ConfigureAwait(false); + await using AsyncServiceScope scope = services.CreateAsyncScope(); + SetScope(scope.ServiceProvider); + WorkspacesDbContext database = scope.ServiceProvider + .GetRequiredService(); + WorkspaceStaffOnboarding application = CreateApplication( + applicationId, + SubjectId); + database.StaffOnboardingApplications.Add(application); + await database.SaveChangesAsync().ConfigureAwait(false); + Assert.True(application.IdentityAnchorSweepOrdinal > 0); + } + + private static async Task CreateSchemaAsync(ServiceProvider services) + { + await using AsyncServiceScope scope = services.CreateAsyncScope(); + SetScope(scope.ServiceProvider); + WorkspacesDbContext database = scope.ServiceProvider + .GetRequiredService(); + await database.Database.EnsureCreatedAsync().ConfigureAwait(false); + } + + private static WorkspaceStaffOnboarding CreateApplication( + Guid applicationId, + string subjectId) => WorkspaceStaffOnboarding.Create( + applicationId, + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + Guid.NewGuid(), + subjectId, + $"{subjectId}@example.test", + "Sweep Candidate", + null, + null, + null, + null, + null, + null, + Now).Value; + + private static void SetScope(IServiceProvider provider) => provider + .GetRequiredService() + .SetScope(TenantId); + + private sealed class MutatingFailureCandidateHandler( + WorkspacesDbContext database, + PoisonMutationProbe poison) + : ICommandHandler< + ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommand, + WorkspaceStaffIdentityAnchorSweepCandidateResult> + { + public async Task> HandleAsync( + ReconcileWorkspaceStaffIdentityAnchorSweepCandidateCommand + command, + CancellationToken cancellationToken) + { + WorkspaceStaffOnboarding application = await database + .StaffOnboardingApplications + .SingleAsync( + candidate => candidate.Id == command.ApplicationId, + cancellationToken) + .ConfigureAwait(false); + Assert.True(application.ObserveInvitationAccepted(Now).IsSuccess); + database.OutboxMessages.Add(new OutboxMessage( + poison.OutboxId, + "bunkfy.workspaces.sweep-poison.v1", + "sweep-poison", + version: 1, + TenantId, + Now, + "{}", + Now)); + return Result.Failure< + WorkspaceStaffIdentityAnchorSweepCandidateResult>( + new Error( + "Workspaces.SweepPoisonFailure", + "The injected candidate fails after mutation.")); + } + } + + private sealed class UnresolvedOutcomeReader + : IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + { + public Task> ReadAsync( + IReadOnlyList< + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest> + requests, + CancellationToken cancellationToken = default) => + Task.FromResult>( + requests.Select(request => new + StaffWorkspaceOnboardingIdentityAnchorOutcome( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Unresolved, + Guid.NewGuid(), + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Exact, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + Guid.NewGuid())) + .ToArray()); + } + + private sealed class UnexpectedResolutionRecorder + : IStaffWorkspaceOnboardingIdentityAnchorResolutionRecorder + { + public Task + RecordAsync( + StaffWorkspaceOnboardingIdentityAnchorResolutionRequest + request, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException( + "The failed candidate must not request a resolution record."); + } + + private sealed class PoisonMutationProbe + { + public Guid OutboxId { get; } = Guid.NewGuid(); + } + + private sealed class SweepBarrierProbe + { + public TaskCompletionSource Acquired { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private sealed class PausingCrossGraphMutationLock( + IWorkspaceCrossGraphMutationLock inner, + SweepBarrierProbe probe) + : IWorkspaceCrossGraphMutationLock + { + public async Task AcquireAsync(CancellationToken cancellationToken) + { + await inner.AcquireAsync(cancellationToken).ConfigureAwait(false); + probe.Acquired.TrySetResult(); + await probe.Release.Task.WaitAsync(cancellationToken) + .ConfigureAwait(false); + } + } + + private sealed class FixedClock : ISystemClock + { + public DateTimeOffset UtcNow => Now; + } + + private sealed class GuidIdGenerator : IIdGenerator + { + public Guid NewId() => Guid.NewGuid(); + } +} diff --git a/tests/Integration.Tests/Workspaces/WorkspaceStaffIdentityProvisioningAnchorMigrationIntegrationTests.cs b/tests/Integration.Tests/Workspaces/WorkspaceStaffIdentityProvisioningAnchorMigrationIntegrationTests.cs new file mode 100644 index 00000000..f2f37d25 --- /dev/null +++ b/tests/Integration.Tests/Workspaces/WorkspaceStaffIdentityProvisioningAnchorMigrationIntegrationTests.cs @@ -0,0 +1,2712 @@ +namespace Integration.Tests.Workspaces; + +using System.Security.Cryptography; +using System.Text; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Persistence; +using Gma.Framework.Scoping; +using Integration.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class + WorkspaceStaffIdentityProvisioningAnchorMigrationIntegrationTests +{ + private const string PreviousMigration = + "20260811044039_AddWorkspaceStaffDeferredClaimWithdrawals"; + private const string CurrentMigration = + "20260811205004_AddWorkspaceStaffIdentityProvisioningAnchors"; + private const string TenantId = + "10000000-0000-0000-0000-000000000001"; + private const string OtherTenantId = + "10000000-0000-0000-0000-000000000002"; + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task + Upgrade_fails_fast_for_writer_and_rolls_back_schema_and_history() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspaces_anchor_migration_lock_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + + string upgradeScript; + await using (WorkspacesDbContext previous = CreateDbContext( + connectionString, + TenantId)) + { + await previous.Database.GetService() + .MigrateAsync(PreviousMigration) + .ConfigureAwait(false); + upgradeScript = previous.Database.GetService() + .GenerateScript(PreviousMigration, CurrentMigration); + } + + await using NpgsqlConnection writer = new(connectionString); + await writer.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction writerTransaction = + await writer.BeginTransactionAsync().ConfigureAwait(false); + await using (NpgsqlCommand lockCommand = new( + """ + LOCK TABLE workspaces.staff_onboarding_applications + IN ROW EXCLUSIVE MODE + """, + writer, + writerTransaction)) + { + await lockCommand.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + await using (NpgsqlConnection blockedUpgrade = new(connectionString)) + { + await blockedUpgrade.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand upgradeCommand = new( + upgradeScript, + blockedUpgrade) + { + CommandTimeout = 5 + }; + Task upgradeAttempt = upgradeCommand.ExecuteNonQueryAsync(); + await Task.Delay(500).ConfigureAwait(false); + if (!upgradeAttempt.IsCompleted) + { + string activity = await ExecuteScalarAsync( + connectionString, + $""" + SELECT COALESCE(wait_event_type, 'none') || ':' || + COALESCE(wait_event, 'none') || ':' || query + FROM pg_stat_activity + WHERE pid = {blockedUpgrade.ProcessID} + """).ConfigureAwait(false); + Assert.Fail($"Blocked migration activity: {activity}"); + } + + PostgresException blocked = Assert.IsType( + await Assert.ThrowsAnyAsync(async () => + await upgradeAttempt.ConfigureAwait(false)) + .ConfigureAwait(false)); + Assert.Equal(PostgresErrorCodes.LockNotAvailable, blocked.SqlState); + } + + Assert.Equal( + 0, + await ExecuteScalarAsync( + connectionString, + $$""" + SELECT COUNT(*)::integer + FROM workspaces.__ef_migrations_history + WHERE "MigrationId" = '{{CurrentMigration}}' + """).ConfigureAwait(false)); + Assert.False(await ExecuteScalarAsync( + connectionString, + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'workspaces' + AND table_name = 'staff_onboarding_applications' + AND column_name = 'IdentityAnchorSweepOrdinal') + """).ConfigureAwait(false)); + Assert.True(await ExecuteScalarAsync( + connectionString, + """ + SELECT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE connamespace = 'workspaces'::regnamespace + AND conname = + 'CK_workspaces_tenant_destroy_operation_progress') + """).ConfigureAwait(false)); + + await writerTransaction.CommitAsync().ConfigureAwait(false); + + await using WorkspacesDbContext drainedUpgrade = CreateDbContext( + connectionString, + TenantId); + await drainedUpgrade.Database.GetService() + .MigrateAsync(CurrentMigration) + .ConfigureAwait(false); + Assert.Equal( + 1, + await ExecuteScalarAsync( + connectionString, + $$""" + SELECT COUNT(*)::integer + FROM workspaces.__ef_migrations_history + WHERE "MigrationId" = '{{CurrentMigration}}' + """).ConfigureAwait(false)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task + Down_rejects_active_new_stages_and_maps_only_completed_stage_to_legacy_completion() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspaces_anchor_down_stage_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + await using WorkspacesDbContext context = CreateDbContext( + connectionString, + TenantId); + await context.Database.GetService() + .MigrateAsync(CurrentMigration) + .ConfigureAwait(false); + + await ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO workspaces.workspace_termination_fences ( + "Id", "ProcessId", "CaseId", "ApprovalRevision", + "TerminationEpoch", "PolicyEvidenceSha256", "State", + "CreatedBy", "CreatedAtUtc", "LastChangedBy", + "LastChangedAtUtc", "Version", "ScopeId") + VALUES ( + '61000000-0000-0000-0000-000000000001', + '62000000-0000-0000-0000-000000000001', + '63000000-0000-0000-0000-000000000001', 1, + '64000000-0000-0000-0000-000000000001', repeat('c', 64), 4, + 'migration-test', '2026-08-11T21:00:00Z', 'migration-test', + '2026-08-11T21:01:00Z', 3, '{{TenantId}}'); + INSERT INTO workspaces.tenant_destroy_operations ( + "OperationId", "ScopeId", "RequestSha256", "FenceId", + "SelectedFenceVersion", "ResultingFenceVersion", "BatchSize", + "Stage", "RemovedRecordCount", "CompletedBatchCount", + "ProofVersion", "RemovalProofSha256", "StartedAtUtc", + "UpdatedAtUtc", "ConcurrencyVersion") + VALUES ( + '65000000-0000-0000-0000-000000000001', '{{TenantId}}', + repeat('d', 64), + '61000000-0000-0000-0000-000000000001', 1, 3, 500, 20, 0, + 0, 1, repeat('e', 64), '2026-08-11T21:02:00Z', + '2026-08-11T21:02:00Z', 1) + """).ConfigureAwait(false); + + Exception activeCheckpointStage = + await Assert.ThrowsAnyAsync(() => + context.Database.GetService() + .MigrateAsync(PreviousMigration)).ConfigureAwait(false); + Assert.Equal( + "P0001", + Assert.IsType( + activeCheckpointStage.GetBaseException()).SqlState); + + await ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.tenant_destroy_operations + SET "Stage" = 21 + WHERE "OperationId" = + '65000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false); + Exception activeReceiptStage = + await Assert.ThrowsAnyAsync(() => + context.Database.GetService() + .MigrateAsync(PreviousMigration)).ConfigureAwait(false); + Assert.Equal( + "P0001", + Assert.IsType( + activeReceiptStage.GetBaseException()).SqlState); + + await ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.tenant_destroy_operations + SET "Stage" = 22 + WHERE "OperationId" = + '65000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false); + + await ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO workspaces.inbox_messages ( + "Id", "Handler", "Subject", "EventType", "Version", + "ScopeId", "Status", "Attempts", "OccurredAtUtc", + "CreatedAtUtc", "ProcessingStartedAtUtc", "FailedAtUtc", + "LastError") + VALUES ( + '66000000-0000-0000-0000-000000000001', + 'identity-anchor-migration-test', 'subject:failed-inbox', + 'workspace-staff-onboarding-identity-anchor-resolved', 1, + '{{TenantId}}', 4, 1, '2026-08-11T21:03:00Z', + '2026-08-11T21:03:00Z', '2026-08-11T21:03:00Z', + '2026-08-11T21:04:00Z', 'retryable migration test failure') + """).ConfigureAwait(false); + Exception failedInboxMessage = + await Assert.ThrowsAnyAsync(() => + context.Database.GetService() + .MigrateAsync(PreviousMigration)).ConfigureAwait(false); + Assert.Equal( + "P0001", + Assert.IsType( + failedInboxMessage.GetBaseException()).SqlState); + await ExecuteNonQueryAsync( + connectionString, + """ + DELETE FROM workspaces.inbox_messages + WHERE "Id" = '66000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false); + + await context.Database.GetService() + .MigrateAsync(PreviousMigration) + .ConfigureAwait(false); + + Assert.Equal( + 20, + await ExecuteScalarAsync( + connectionString, + """ + SELECT "Stage" + FROM workspaces.tenant_destroy_operations + WHERE "OperationId" = + '65000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false)); + Assert.False(await ExecuteScalarAsync( + connectionString, + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'workspaces' + AND table_name = 'staff_onboarding_applications' + AND column_name = 'IdentityAnchorSweepOrdinal') + """).ConfigureAwait(false)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task + Upgrade_redacts_only_bound_legacy_rows_and_backfills_exact_protocol() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspaces_anchor_upgrade_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + + await using (WorkspacesDbContext previous = CreateDbContext( + connectionString, + TenantId)) + { + await previous.Database.GetService() + .MigrateAsync(PreviousMigration) + .ConfigureAwait(false); + } + + await ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO workspaces.staff_onboarding_applications ( + "Id", "SourceKind", "SourceId", "ClaimId", "ClaimVersion", + "SubjectId", "VerifiedAccountEmail", "DisplayName", + "LegalName", "WorkEmail", "WorkPhone", "EmployeeNumber", + "JobTitle", "Department", "Status", "StaffMemberId", + "FailureCode", "Version", "CreatedAtUtc", "LastChangedAtUtc", + "ScopeId") + VALUES + ('21000000-0000-0000-0000-000000000001', 1, + '22000000-0000-0000-0000-000000000001', NULL, NULL, + 'subject:legacy-bound', 'bound@example.test', + 'Bound Applicant', 'Bound Legal', 'bound-work@example.test', + '+1-555-0101', 'EMP-BOUND', 'Operator', 'Operations', 4, + '23000000-0000-0000-0000-000000000001', 'legacy-failure', 7, + '2026-08-11T19:00:00Z', '2026-08-11T19:01:00Z', + '{{TenantId}}'), + ('21000000-0000-0000-0000-000000000002', 1, + '22000000-0000-0000-0000-000000000002', NULL, NULL, + 'subject:legacy-completed', NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, 5, + '23000000-0000-0000-0000-000000000002', NULL, 9, + '2026-08-11T19:00:00Z', '2026-08-11T19:02:00Z', + '{{TenantId}}'), + ('21000000-0000-0000-0000-000000000003', 2, + '22000000-0000-0000-0000-000000000003', NULL, NULL, + 'subject:legacy-unbound', 'unbound@example.test', + 'Unbound Applicant', NULL, NULL, NULL, NULL, NULL, NULL, 1, + NULL, NULL, 3, '2026-08-11T19:00:00Z', + '2026-08-11T19:03:00Z', '{{TenantId}}'); + + INSERT INTO workspaces.staff_access_processes ( + "Id", "StaffMemberId", "SubjectId", "TargetState", + "TargetStaffVersion", "EffectiveOn", "RequestedBy", "State", + "FailureCode", "Version", "CreatedAtUtc", "LastChangedAtUtc", + "CompletedAtUtc", "ScopeId") + VALUES + ('24000000-0000-0000-0000-000000000001', + '23000000-0000-0000-0000-000000000001', 'subject:active', 1, + 2, '2026-08-11', 'migration-test', 1, NULL, 1, + '2026-08-11T19:00:00Z', '2026-08-11T19:00:00Z', NULL, + '{{TenantId}}'), + ('24000000-0000-0000-0000-000000000002', + '23000000-0000-0000-0000-000000000002', + 'subject:suspended', 2, 2, '2026-08-11', 'migration-test', 1, + NULL, 1, '2026-08-11T19:00:00Z', + '2026-08-11T19:00:00Z', NULL, '{{TenantId}}'), + ('24000000-0000-0000-0000-000000000003', + '23000000-0000-0000-0000-000000000003', 'subject:departed', 3, + 2, '2026-08-11', 'migration-test', 1, NULL, 1, + '2026-08-11T19:00:00Z', '2026-08-11T19:00:00Z', NULL, + '{{TenantId}}'); + + INSERT INTO workspaces.workspace_termination_fences ( + "Id", "ProcessId", "CaseId", "ApprovalRevision", + "TerminationEpoch", "PolicyEvidenceSha256", "State", + "CreatedBy", "CreatedAtUtc", "LastChangedBy", + "LastChangedAtUtc", "Version", "ScopeId") + VALUES ( + '25000000-0000-0000-0000-000000000001', + '26000000-0000-0000-0000-000000000001', + '27000000-0000-0000-0000-000000000001', 1, + '28000000-0000-0000-0000-000000000001', repeat('c', 64), 4, + 'migration-test', '2026-08-11T18:00:00Z', 'migration-test', + '2026-08-11T18:01:00Z', 3, '{{TenantId}}'); + + INSERT INTO workspaces.tenant_destroy_operations ( + "OperationId", "ScopeId", "RequestSha256", "FenceId", + "SelectedFenceVersion", "ResultingFenceVersion", "BatchSize", + "Stage", "RemovedRecordCount", "CompletedBatchCount", + "ProofVersion", "RemovalProofSha256", "StartedAtUtc", + "UpdatedAtUtc", "ConcurrencyVersion") + VALUES ( + '29000000-0000-0000-0000-000000000001', '{{TenantId}}', + repeat('d', 64), + '25000000-0000-0000-0000-000000000001', 1, 3, 500, 20, 0, + 0, 1, repeat('e', 64), '2026-08-11T18:02:00Z', + '2026-08-11T18:03:00Z', 1); + """).ConfigureAwait(false); + + await using (WorkspacesDbContext upgrade = CreateDbContext( + connectionString, + TenantId)) + { + await upgrade.Database.GetService() + .MigrateAsync(CurrentMigration) + .ConfigureAwait(false); + } + + Assert.Equal( + 22, + await ExecuteScalarAsync( + connectionString, + """ + SELECT "Stage" + FROM workspaces.tenant_destroy_operations + WHERE "OperationId" = + '29000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false)); + Assert.True(await ExecuteScalarAsync( + connectionString, + """ + SELECT "Version" = 8 + AND "LastChangedAtUtc" > '2026-08-11T19:01:00Z' + AND "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 + FROM workspaces.staff_onboarding_applications + WHERE "Id" = '21000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false)); + Assert.True(await ExecuteScalarAsync( + connectionString, + """ + SELECT "Version" = 9 + AND "LastChangedAtUtc" = '2026-08-11T19:02:00Z' + FROM workspaces.staff_onboarding_applications + WHERE "Id" = '21000000-0000-0000-0000-000000000002' + """).ConfigureAwait(false)); + Assert.True(await ExecuteScalarAsync( + connectionString, + """ + SELECT "Version" = 3 + AND "LastChangedAtUtc" = '2026-08-11T19:03:00Z' + AND "VerifiedAccountEmail" = 'unbound@example.test' + AND "DisplayName" = 'Unbound Applicant' + FROM workspaces.staff_onboarding_applications + WHERE "Id" = '21000000-0000-0000-0000-000000000003' + """).ConfigureAwait(false)); + Assert.True(await ExecuteScalarAsync( + connectionString, + """ + SELECT COUNT(*) = 3 + AND COUNT(DISTINCT "IdentityAnchorSweepOrdinal") = 3 + AND MIN("IdentityAnchorSweepOrdinal") > 0 + AND bool_and("IdentityAnchorExpectedResolutionEventId" IS NULL) + AND bool_and("IdentityAnchorContinuationEventId" IS NULL) + AND bool_and("IdentityAnchorResolutionEventId" IS NULL) + AND bool_and("IdentityAnchorResolutionStaffMemberId" IS NULL) + FROM workspaces.staff_onboarding_applications + """).ConfigureAwait(false)); + Assert.True(await ExecuteScalarAsync( + connectionString, + """ + SELECT bool_and( + ("TargetState" = 1 AND "RestorationDisposition" = 2) OR + ("TargetState" IN (2, 3) AND "RestorationDisposition" = 1)) + FROM workspaces.staff_access_processes + """).ConfigureAwait(false)); + + long newcomerOrdinal = await ExecuteScalarAsync( + connectionString, + $$""" + INSERT INTO workspaces.staff_onboarding_applications ( + "Id", "SourceKind", "SourceId", "SubjectId", + "VerifiedAccountEmail", "DisplayName", "Status", "Version", + "CreatedAtUtc", "LastChangedAtUtc", "ScopeId") + VALUES ( + '2a000000-0000-0000-0000-000000000001', 2, + '2b000000-0000-0000-0000-000000000001', 'subject:newcomer', + 'newcomer@example.test', 'New Applicant', 1, 1, + '2026-08-11T21:00:00Z', '2026-08-11T21:00:00Z', + '{{TenantId}}') + RETURNING "IdentityAnchorSweepOrdinal" + """).ConfigureAwait(false); + Assert.True(newcomerOrdinal > 0); + + long rewrittenForgedOrdinal = await ExecuteScalarAsync( + connectionString, + $$""" + INSERT INTO workspaces.staff_onboarding_applications ( + "Id", "SourceKind", "SourceId", "SubjectId", + "VerifiedAccountEmail", "DisplayName", "Status", + "Version", "CreatedAtUtc", "LastChangedAtUtc", "ScopeId", + "IdentityAnchorSweepOrdinal") + OVERRIDING SYSTEM VALUE + VALUES ( + '2a000000-0000-0000-0000-000000000002', 2, + '2b000000-0000-0000-0000-000000000002', + 'subject:forged-ordinal', 'forged@example.test', + 'Forged Applicant', 1, 1, '2026-08-11T21:00:00Z', + '2026-08-11T21:00:00Z', '{{TenantId}}', 9000000) + RETURNING "IdentityAnchorSweepOrdinal" + """).ConfigureAwait(false); + Assert.NotEqual(9000000, rewrittenForgedOrdinal); + Assert.True(rewrittenForgedOrdinal > newcomerOrdinal); + + await using (NpgsqlConnection ordinalSession = new(connectionString)) + { + await ordinalSession.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand allocated = new( + $$""" + INSERT INTO workspaces.staff_onboarding_applications ( + "Id", "SourceKind", "SourceId", "SubjectId", + "VerifiedAccountEmail", "DisplayName", "Status", "Version", + "CreatedAtUtc", "LastChangedAtUtc", "ScopeId") + VALUES ( + '2a000000-0000-0000-0000-000000000003', 2, + '2b000000-0000-0000-0000-000000000003', + 'subject:allocated-before-replay', 'allocated@example.test', + 'Allocated Applicant', 1, 1, '2026-08-11T21:00:00Z', + '2026-08-11T21:00:00Z', '{{TenantId}}') + RETURNING "IdentityAnchorSweepOrdinal" + """, + ordinalSession); + long allocatedOrdinal = Assert.IsType( + await allocated.ExecuteScalarAsync().ConfigureAwait(false)); + await using NpgsqlCommand replay = new( + $$""" + INSERT INTO workspaces.staff_onboarding_applications ( + "Id", "SourceKind", "SourceId", "SubjectId", + "VerifiedAccountEmail", "DisplayName", "Status", "Version", + "CreatedAtUtc", "LastChangedAtUtc", "ScopeId", + "IdentityAnchorSweepOrdinal") + OVERRIDING SYSTEM VALUE + VALUES ( + '2a000000-0000-0000-0000-000000000004', 2, + '2b000000-0000-0000-0000-000000000004', + 'subject:cross-tenant-currval-replay', + 'replay@example.test', 'Replay Applicant', 1, 1, + '2026-08-11T21:00:00Z', '2026-08-11T21:00:00Z', + '{{OtherTenantId}}', {{allocatedOrdinal}}) + RETURNING "IdentityAnchorSweepOrdinal" + """, + ordinalSession); + long replayedCurrval = Assert.IsType( + await replay.ExecuteScalarAsync().ConfigureAwait(false)); + Assert.True(replayedCurrval > allocatedOrdinal); + + long rolledBackOrdinal; + await using (NpgsqlTransaction rolledBackAllocation = + await ordinalSession.BeginTransactionAsync() + .ConfigureAwait(false)) + { + await using NpgsqlCommand allocateThenRollback = new( + $$""" + INSERT INTO workspaces.staff_onboarding_applications ( + "Id", "SourceKind", "SourceId", "SubjectId", + "VerifiedAccountEmail", "DisplayName", "Status", + "Version", "CreatedAtUtc", "LastChangedAtUtc", + "ScopeId") + VALUES ( + '2a000000-0000-0000-0000-000000000005', 1, + '2b000000-0000-0000-0000-000000000005', + 'subject:rolled-back-ordinal', + 'rolled-back@example.test', 'Rolled Back', 1, 1, + '2026-08-11T21:00:00Z', '2026-08-11T21:00:00Z', + '{{TenantId}}') + RETURNING "IdentityAnchorSweepOrdinal" + """, + ordinalSession, + rolledBackAllocation); + rolledBackOrdinal = Assert.IsType( + await allocateThenRollback.ExecuteScalarAsync() + .ConfigureAwait(false)); + await rolledBackAllocation.RollbackAsync() + .ConfigureAwait(false); + } + + long committedHighWater = await ExecuteScalarAsync( + connectionString, + $$""" + INSERT INTO workspaces.staff_onboarding_applications ( + "Id", "SourceKind", "SourceId", "SubjectId", + "VerifiedAccountEmail", "DisplayName", "Status", + "Version", "CreatedAtUtc", "LastChangedAtUtc", "ScopeId") + VALUES ( + '2a000000-0000-0000-0000-000000000006', 1, + '2b000000-0000-0000-0000-000000000006', + 'subject:later-committed-ordinal', + 'later@example.test', 'Later Committed', 1, 1, + '2026-08-11T21:00:00Z', '2026-08-11T21:00:00Z', + '{{TenantId}}') + RETURNING "IdentityAnchorSweepOrdinal" + """).ConfigureAwait(false); + Assert.True(committedHighWater > rolledBackOrdinal); + + await using NpgsqlCommand rolledBackCurrvalReplay = new( + $$""" + INSERT INTO workspaces.staff_onboarding_applications ( + "Id", "SourceKind", "SourceId", "SubjectId", + "VerifiedAccountEmail", "DisplayName", "Status", + "Version", "CreatedAtUtc", "LastChangedAtUtc", "ScopeId", + "IdentityAnchorSweepOrdinal") + OVERRIDING SYSTEM VALUE + VALUES ( + '2a000000-0000-0000-0000-000000000007', 2, + '2b000000-0000-0000-0000-000000000007', + 'subject:rolled-back-currval-replay', + 'rollback-replay@example.test', 'Rollback Replay', 1, 1, + '2026-08-11T21:00:00Z', '2026-08-11T21:00:00Z', + '{{OtherTenantId}}', {{rolledBackOrdinal}}) + RETURNING "IdentityAnchorSweepOrdinal" + """, + ordinalSession); + long actualOrdinal = Assert.IsType( + await rolledBackCurrvalReplay.ExecuteScalarAsync() + .ConfigureAwait(false)); + Assert.True( + actualOrdinal > committedHighWater, + $"Rolled-back currval {rolledBackOrdinal} was persisted " + + $"behind committed high-water {committedHighWater}."); + } + + PostgresException mutatedOrdinal = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.staff_onboarding_applications + SET "IdentityAnchorSweepOrdinal" = + "IdentityAnchorSweepOrdinal" + 1 + WHERE "Id" = + '2a000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.True(mutatedOrdinal.SqlState is "P0001" or "428C9"); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task + Raw_onboarding_anchor_protocol_rejects_partial_changed_and_mismatched_coordinates() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspaces_anchor_raw_protocol_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + await using (WorkspacesDbContext context = CreateDbContext( + connectionString, + TenantId)) + { + await context.Database.GetService() + .MigrateAsync(CurrentMigration) + .ConfigureAwait(false); + } + + long ordinaryUnboundOrdinal = await ExecuteScalarAsync( + connectionString, + $$""" + INSERT INTO workspaces.staff_onboarding_applications ( + "Id", "SourceKind", "SourceId", "SubjectId", + "VerifiedAccountEmail", "DisplayName", "Status", "Version", + "CreatedAtUtc", "LastChangedAtUtc", "ScopeId") + VALUES ( + '30000000-0000-0000-0000-000000000001', 1, + '30000000-0000-0000-0000-000000000011', + 'subject:ordinary-unbound-insert', 'ordinary@example.test', + 'Ordinary Unbound', 1, 1, '2026-08-11T20:59:00Z', + '2026-08-11T20:59:00Z', '{{TenantId}}') + RETURNING "IdentityAnchorSweepOrdinal" + """).ConfigureAwait(false); + Assert.True(ordinaryUnboundOrdinal > 0); + + PostgresException boundContinuationInsert = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO workspaces.staff_onboarding_applications ( + "Id", "SourceKind", "SourceId", "SubjectId", "Status", + "StaffMemberId", + "IdentityAnchorExpectedResolutionEventId", + "IdentityAnchorContinuationEventId", "Version", + "CreatedAtUtc", "LastChangedAtUtc", "ScopeId") + VALUES ( + '30000000-0000-0000-0000-000000000002', 2, + '30000000-0000-0000-0000-000000000012', + 'subject:forged-bound-continuation', 4, + '30000000-0000-0000-0000-000000000022', + '30000000-0000-0000-0000-000000000032', + '30000000-0000-0000-0000-000000000042', 2, + '2026-08-11T20:59:00Z', '2026-08-11T21:00:00Z', + '{{TenantId}}') + """)).ConfigureAwait(false); + Assert.Equal("P0001", boundContinuationInsert.SqlState); + + PostgresException terminalResolutionInsert = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO workspaces.staff_onboarding_applications ( + "Id", "SourceKind", "SourceId", "SubjectId", "Status", + "StaffMemberId", + "IdentityAnchorExpectedResolutionEventId", + "IdentityAnchorResolutionEventId", + "IdentityAnchorResolutionStaffMemberId", + "IdentityAnchorResolutionApplicationVersion", + "IdentityAnchorResolutionDisposition", + "IdentityAnchorResolutionIntentAtUtc", "Version", + "CreatedAtUtc", "LastChangedAtUtc", "ScopeId") + VALUES ( + '30000000-0000-0000-0000-000000000003', 1, + '30000000-0000-0000-0000-000000000013', + 'subject:forged-terminal-resolution', 5, + '30000000-0000-0000-0000-000000000023', + '30000000-0000-0000-0000-000000000033', + '30000000-0000-0000-0000-000000000033', + '30000000-0000-0000-0000-000000000023', 3, 1, + '2026-08-11T21:00:00Z', 3, + '2026-08-11T20:59:00Z', '2026-08-11T21:00:00Z', + '{{TenantId}}') + """)).ConfigureAwait(false); + Assert.Equal("P0001", terminalResolutionInsert.SqlState); + Assert.True(await ExecuteScalarAsync( + connectionString, + """ + SELECT COUNT(*) = 0 + FROM workspaces.staff_onboarding_applications + WHERE "Id" IN ( + '30000000-0000-0000-0000-000000000002', + '30000000-0000-0000-0000-000000000003') + """).ConfigureAwait(false)); + + await ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO workspaces.staff_onboarding_applications ( + "Id", "SourceKind", "SourceId", "SubjectId", + "VerifiedAccountEmail", "DisplayName", "Status", "Version", + "CreatedAtUtc", "LastChangedAtUtc", "ScopeId") + VALUES ( + '31000000-0000-0000-0000-000000000001', 1, + '32000000-0000-0000-0000-000000000001', + 'subject:anchor-protocol', 'anchor@example.test', + 'Anchor Applicant', 1, 1, '2026-08-11T21:00:00Z', + '2026-08-11T21:00:00Z', '{{TenantId}}') + """).ConfigureAwait(false); + + await ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO workspaces.staff_onboarding_applications ( + "Id", "SourceKind", "SourceId", "SubjectId", + "VerifiedAccountEmail", "DisplayName", "Status", "Version", + "CreatedAtUtc", "LastChangedAtUtc", "ScopeId") + VALUES ( + '31000000-0000-0000-0000-000000000002', 1, + '32000000-0000-0000-0000-000000000002', + 'subject:direct-terminal-anchor', 'terminal@example.test', + 'Direct Terminal Applicant', 1, 1, + '2026-08-11T21:00:00Z', '2026-08-11T21:00:00Z', + '{{TenantId}}') + """).ConfigureAwait(false); + + PostgresException partialExpected = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.staff_onboarding_applications + SET "IdentityAnchorExpectedResolutionEventId" = + '33000000-0000-0000-0000-000000000001', + "Version" = 2, + "LastChangedAtUtc" = '2026-08-11T21:01:00Z' + WHERE "Id" = + '31000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.True(partialExpected.SqlState is "P0001" or "23514"); + + await ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.staff_onboarding_applications + SET "StaffMemberId" = + '34000000-0000-0000-0000-000000000001', + "IdentityAnchorExpectedResolutionEventId" = + '33000000-0000-0000-0000-000000000001', + "IdentityAnchorContinuationEventId" = + '35000000-0000-0000-0000-000000000001', + "VerifiedAccountEmail" = NULL, + "DisplayName" = NULL, + "Status" = 4, + "Version" = 2, + "LastChangedAtUtc" = '2026-08-11T21:01:00Z' + WHERE "Id" = '31000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false); + + PostgresException changedExpected = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.staff_onboarding_applications + SET "IdentityAnchorExpectedResolutionEventId" = + '33000000-0000-0000-0000-000000000002', + "Version" = 3, + "LastChangedAtUtc" = '2026-08-11T21:02:00Z' + WHERE "Id" = + '31000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.Equal("P0001", changedExpected.SqlState); + + PostgresException clearedContinuation = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.staff_onboarding_applications + SET "IdentityAnchorContinuationEventId" = NULL, + "Version" = 3, + "LastChangedAtUtc" = '2026-08-11T21:02:00Z' + WHERE "Id" = + '31000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.Equal("P0001", clearedContinuation.SqlState); + + PostgresException partialResolution = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.staff_onboarding_applications + SET "IdentityAnchorResolutionEventId" = + '33000000-0000-0000-0000-000000000001', + "Status" = 5, + "Version" = 3, + "LastChangedAtUtc" = '2026-08-11T21:02:00Z' + WHERE "Id" = + '31000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.Equal("P0001", partialResolution.SqlState); + + PostgresException mismatchedDisposition = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.staff_onboarding_applications + SET "IdentityAnchorResolutionEventId" = + '33000000-0000-0000-0000-000000000001', + "IdentityAnchorResolutionStaffMemberId" = + '34000000-0000-0000-0000-000000000001', + "IdentityAnchorResolutionApplicationVersion" = 3, + "IdentityAnchorResolutionDisposition" = 2, + "IdentityAnchorResolutionIntentAtUtc" = + '2026-08-11T21:02:00Z', + "Status" = 5, + "Version" = 3, + "LastChangedAtUtc" = '2026-08-11T21:02:00Z' + WHERE "Id" = + '31000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.Equal(PostgresErrorCodes.CheckViolation, + mismatchedDisposition.SqlState); + + PostgresException observationWithFirstResolution = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.staff_onboarding_applications + SET "IdentityAnchorResolutionEventId" = + '33000000-0000-0000-0000-000000000001', + "IdentityAnchorResolutionStaffMemberId" = + '34000000-0000-0000-0000-000000000001', + "IdentityAnchorResolutionApplicationVersion" = 3, + "IdentityAnchorResolutionDisposition" = 1, + "IdentityAnchorResolutionIntentAtUtc" = + '2026-08-11T21:02:00Z', + "IdentityAnchorResolutionObservedAtUtc" = + '2026-08-11T21:02:00Z', + "Status" = 5, + "Version" = 3, + "LastChangedAtUtc" = '2026-08-11T21:02:00Z' + WHERE "Id" = + '31000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.Equal("P0001", observationWithFirstResolution.SqlState); + + await using (NpgsqlConnection sameTransaction = + new(connectionString)) + { + await sameTransaction.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction sameTransactionResolution = + await sameTransaction.BeginTransactionAsync() + .ConfigureAwait(false); + await ExecuteNonQueryAsync( + sameTransaction, + sameTransactionResolution, + """ + UPDATE workspaces.staff_onboarding_applications + SET "IdentityAnchorResolutionEventId" = + '33000000-0000-0000-0000-000000000001', + "IdentityAnchorResolutionStaffMemberId" = + '34000000-0000-0000-0000-000000000001', + "IdentityAnchorResolutionApplicationVersion" = 3, + "IdentityAnchorResolutionDisposition" = 1, + "IdentityAnchorResolutionIntentAtUtc" = + '2026-08-11T21:02:00Z', + "Status" = 5, + "Version" = 3, + "LastChangedAtUtc" = '2026-08-11T21:02:00Z' + WHERE "Id" = + '31000000-0000-0000-0000-000000000001' + """) + .ConfigureAwait(false); + + PostgresException observationBeforeIntentCommit = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + sameTransaction, + sameTransactionResolution, + """ + UPDATE workspaces.staff_onboarding_applications + SET "IdentityAnchorResolutionObservedAtUtc" = + '2026-08-11T21:03:00Z', + "Version" = 4, + "LastChangedAtUtc" = '2026-08-11T21:03:00Z' + WHERE "Id" = + '31000000-0000-0000-0000-000000000001' + """)); + Assert.Equal("P0001", observationBeforeIntentCommit.SqlState); + await sameTransactionResolution.RollbackAsync() + .ConfigureAwait(false); + } + + await ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.staff_onboarding_applications + SET "IdentityAnchorResolutionEventId" = + '33000000-0000-0000-0000-000000000001', + "IdentityAnchorResolutionStaffMemberId" = + '34000000-0000-0000-0000-000000000001', + "IdentityAnchorResolutionApplicationVersion" = 3, + "IdentityAnchorResolutionDisposition" = 1, + "IdentityAnchorResolutionIntentAtUtc" = + '2026-08-11T21:02:00Z', + "Status" = 5, + "Version" = 3, + "LastChangedAtUtc" = '2026-08-11T21:02:00Z' + WHERE "Id" = '31000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false); + Assert.True(await ExecuteScalarAsync( + connectionString, + """ + SELECT "IdentityAnchorResolutionEventId" = + '33000000-0000-0000-0000-000000000001' + AND "IdentityAnchorResolutionObservedAtUtc" IS NULL + AND "Version" = 3 + FROM workspaces.staff_onboarding_applications + WHERE "Id" = '31000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false)); + + PostgresException earlyObservation = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.staff_onboarding_applications + SET "IdentityAnchorResolutionObservedAtUtc" = + '2026-08-11T21:01:59Z', + "Version" = 4, + "LastChangedAtUtc" = '2026-08-11T21:01:59Z' + WHERE "Id" = + '31000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.True(earlyObservation.SqlState is "P0001" or "23514"); + + await ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.staff_onboarding_applications + SET "IdentityAnchorResolutionObservedAtUtc" = + '2026-08-11T21:03:00Z', + "Version" = 4, + "LastChangedAtUtc" = '2026-08-11T21:03:00Z' + WHERE "Id" = '31000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false); + + PostgresException changedObservation = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.staff_onboarding_applications + SET "IdentityAnchorResolutionObservedAtUtc" = + '2026-08-11T21:04:00Z', + "Version" = 5, + "LastChangedAtUtc" = '2026-08-11T21:04:00Z' + WHERE "Id" = + '31000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.Equal("P0001", changedObservation.SqlState); + + await ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.staff_onboarding_applications + SET "StaffMemberId" = + '34000000-0000-0000-0000-000000000002', + "IdentityAnchorExpectedResolutionEventId" = + '33000000-0000-0000-0000-000000000002', + "IdentityAnchorResolutionEventId" = + '33000000-0000-0000-0000-000000000002', + "IdentityAnchorResolutionStaffMemberId" = + '34000000-0000-0000-0000-000000000002', + "IdentityAnchorResolutionApplicationVersion" = 2, + "IdentityAnchorResolutionDisposition" = 1, + "IdentityAnchorResolutionIntentAtUtc" = + '2026-08-11T21:01:00Z', + "VerifiedAccountEmail" = NULL, + "DisplayName" = NULL, + "Status" = 5, + "Version" = 2, + "LastChangedAtUtc" = '2026-08-11T21:01:00Z' + WHERE "Id" = '31000000-0000-0000-0000-000000000002' + """).ConfigureAwait(false); + Assert.True(await ExecuteScalarAsync( + connectionString, + """ + SELECT "IdentityAnchorContinuationEventId" IS NULL + AND "IdentityAnchorResolutionEventId" = + '33000000-0000-0000-0000-000000000002' + AND "Status" = 5 + AND "Version" = 2 + FROM workspaces.staff_onboarding_applications + WHERE "Id" = '31000000-0000-0000-0000-000000000002' + """).ConfigureAwait(false)); + + PostgresException continuationAfterTerminalResolution = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.staff_onboarding_applications + SET "IdentityAnchorContinuationEventId" = + '35000000-0000-0000-0000-000000000002', + "Version" = 3, + "LastChangedAtUtc" = '2026-08-11T21:02:00Z' + WHERE "Id" = + '31000000-0000-0000-0000-000000000002' + """)).ConfigureAwait(false); + Assert.Equal("P0001", continuationAfterTerminalResolution.SqlState); + Assert.True(await ExecuteScalarAsync( + connectionString, + """ + SELECT "IdentityAnchorContinuationEventId" IS NULL + AND "IdentityAnchorResolutionEventId" = + '33000000-0000-0000-0000-000000000002' + AND "Version" = 2 + FROM workspaces.staff_onboarding_applications + WHERE "Id" = '31000000-0000-0000-0000-000000000002' + """).ConfigureAwait(false)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task + Raw_access_and_checkpoint_protocols_reject_ambiguous_or_nonmonotonic_state() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspaces_anchor_control_state_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + await using (WorkspacesDbContext context = CreateDbContext( + connectionString, + TenantId)) + { + await context.Database.GetService() + .MigrateAsync(CurrentMigration) + .ConfigureAwait(false); + } + + await ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO workspaces.staff_access_processes ( + "Id", "StaffMemberId", "SubjectId", "TargetState", + "RestorationDisposition", "TargetStaffVersion", "EffectiveOn", + "RequestedBy", "State", "Version", "CreatedAtUtc", + "LastChangedAtUtc", "ScopeId") + VALUES ( + '41000000-0000-0000-0000-000000000001', + '42000000-0000-0000-0000-000000000001', + 'subject:suppressed', 1, 3, 2, '2026-08-11', + 'migration-test', 1, 1, '2026-08-11T21:00:00Z', + '2026-08-11T21:00:00Z', '{{TenantId}}') + """).ConfigureAwait(false); + PostgresException snapshotAfterSuppressed = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + """ + INSERT INTO workspaces.staff_access_profile_snapshots ( + "ProfileId", "ProcessId", "AssignmentScope") + VALUES ( + '43000000-0000-0000-0000-000000000001', + '41000000-0000-0000-0000-000000000001', + 'tenant:10000000000000000000000000000001') + """)).ConfigureAwait(false); + Assert.Equal("P0001", snapshotAfterSuppressed.SqlState); + + await ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO workspaces.staff_access_processes ( + "Id", "StaffMemberId", "SubjectId", "TargetState", + "RestorationDisposition", "TargetStaffVersion", "EffectiveOn", + "RequestedBy", "State", "Version", "CreatedAtUtc", + "LastChangedAtUtc", "ScopeId") + VALUES ( + '41000000-0000-0000-0000-000000000002', + '42000000-0000-0000-0000-000000000002', + 'subject:restore-snapshot', 1, 2, 2, '2026-08-11', + 'migration-test', 1, 1, '2026-08-11T21:00:00Z', + '2026-08-11T21:00:00Z', '{{TenantId}}'); + INSERT INTO workspaces.staff_access_profile_snapshots ( + "ProfileId", "ProcessId", "AssignmentScope") + VALUES ( + '43000000-0000-0000-0000-000000000002', + '41000000-0000-0000-0000-000000000002', + 'tenant:10000000000000000000000000000001'); + """).ConfigureAwait(false); + PostgresException suppressAfterSnapshot = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.staff_access_processes + SET "RestorationDisposition" = 3 + WHERE "Id" = + '41000000-0000-0000-0000-000000000002' + """)).ConfigureAwait(false); + Assert.Equal("P0001", suppressAfterSnapshot.SqlState); + + PostgresException mismatchedAccessDisposition = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO workspaces.staff_access_processes ( + "Id", "StaffMemberId", "SubjectId", "TargetState", + "RestorationDisposition", "TargetStaffVersion", + "EffectiveOn", "RequestedBy", "State", "Version", + "CreatedAtUtc", "LastChangedAtUtc", "ScopeId") + VALUES ( + '41000000-0000-0000-0000-000000000003', + '42000000-0000-0000-0000-000000000003', + 'subject:mismatched-disposition', 2, 2, 2, + '2026-08-11', 'migration-test', 1, 1, + '2026-08-11T21:00:00Z', '2026-08-11T21:00:00Z', + '{{TenantId}}') + """)).ConfigureAwait(false); + Assert.Equal(PostgresErrorCodes.CheckViolation, + mismatchedAccessDisposition.SqlState); + + await ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO workspaces.staff_onboarding_applications ( + "Id", "SourceKind", "SourceId", "SubjectId", + "VerifiedAccountEmail", "DisplayName", "Status", "Version", + "CreatedAtUtc", "LastChangedAtUtc", "ScopeId") + VALUES + ('4b000000-0000-0000-0000-000000000001', 1, + '4c000000-0000-0000-0000-000000000001', + 'subject:checkpoint-first', 'checkpoint-first@example.test', + 'Checkpoint First', 1, 1, '2026-08-11T21:00:00Z', + '2026-08-11T21:00:00Z', '{{TenantId}}'), + ('4b000000-0000-0000-0000-000000000002', 2, + '4c000000-0000-0000-0000-000000000002', + 'subject:checkpoint-second', + 'checkpoint-second@example.test', 'Checkpoint Second', 1, 1, + '2026-08-11T21:00:00Z', '2026-08-11T21:00:00Z', + '{{TenantId}}') + """).ConfigureAwait(false); + long firstOrdinal = await ExecuteScalarAsync( + connectionString, + $$""" + SELECT min("IdentityAnchorSweepOrdinal") + FROM workspaces.staff_onboarding_applications + WHERE "ScopeId" = '{{TenantId}}' + """).ConfigureAwait(false); + long cycleUpperOrdinal = await ExecuteScalarAsync( + connectionString, + $$""" + SELECT max("IdentityAnchorSweepOrdinal") + FROM workspaces.staff_onboarding_applications + WHERE "ScopeId" = '{{TenantId}}' + """).ConfigureAwait(false); + + await ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO + workspaces.workspace_staff_identity_anchor_sweep_checkpoints ( + "Id", "ProtocolVersion", "CycleId", "CycleUpperOrdinal", + "AfterOrdinal", "CycleStartedAtUtc", "CycleScannedCount", + "CycleNoAnchorCount", "CycleRemovedCount", + "CycleObservedCount", "CycleAlreadyObservedCount", + "CycleDeferredCount", "CycleConflictCount", + "CyclePassOneCommittedCount", + "CycleResolutionRecordConfirmedCount", + "LastCompletedCycleId", "LastCompletedUpperOrdinal", + "LastCompletedAtUtc", "LastCompletedScannedCount", + "LastCompletedNoAnchorCount", "LastCompletedRemovedCount", + "LastCompletedObservedCount", + "LastCompletedAlreadyObservedCount", + "LastCompletedDeferredCount", "LastCompletedConflictCount", + "LastCompletedPassOneCommittedCount", + "LastCompletedResolutionRecordConfirmedCount", + "LastAdvanceId", "LastAdvanceSha256", "LastRunId", + "UpdatedAtUtc", "Version", "ScopeId") + VALUES ( + '44000000-0000-0000-0000-000000000001', 1, NULL, NULL, + NULL, NULL, 0, 0, 0, 0, 0, 0, 0, 0, 0, + NULL, NULL, NULL, 0, 0, 0, 0, 0, 0, 0, 0, 0, + NULL, NULL, NULL, '2026-08-11T21:00:00Z', 1, + '{{TenantId}}') + """).ConfigureAwait(false); + + PostgresException skippedVersion = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + """ + UPDATE + workspaces.workspace_staff_identity_anchor_sweep_checkpoints + SET "Version" = 3, + "UpdatedAtUtc" = '2026-08-11T21:01:00Z' + WHERE "Id" = + '44000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.Equal("P0001", skippedVersion.SqlState); + + await ExecuteNonQueryAsync( + connectionString, + $$""" + UPDATE workspaces.workspace_staff_identity_anchor_sweep_checkpoints + SET "CycleId" = + '45000000-0000-0000-0000-000000000001', + "CycleUpperOrdinal" = {{cycleUpperOrdinal}}, + "CycleStartedAtUtc" = '2026-08-11T21:01:00Z', + "LastRunId" = + '46000000-0000-0000-0000-000000000001', + "UpdatedAtUtc" = '2026-08-11T21:01:00Z', + "Version" = 2 + WHERE "Id" = '44000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false); + + PostgresException malformedDigest = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + $$""" + UPDATE + workspaces.workspace_staff_identity_anchor_sweep_checkpoints + SET "AfterOrdinal" = {{firstOrdinal}}, + "CycleScannedCount" = 1, + "CycleNoAnchorCount" = 1, + "LastAdvanceId" = + '47000000-0000-0000-0000-000000000001', + "LastAdvanceSha256" = repeat('A', 64), + "LastRunId" = + '46000000-0000-0000-0000-000000000002', + "UpdatedAtUtc" = '2026-08-11T21:02:00Z', + "Version" = 3 + WHERE "Id" = + '44000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.Equal("P0001", malformedDigest.SqlState); + + PostgresException forgedAdvanceDigest = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + $$""" + UPDATE + workspaces.workspace_staff_identity_anchor_sweep_checkpoints + SET "AfterOrdinal" = {{firstOrdinal}}, + "CycleScannedCount" = 1, + "CycleNoAnchorCount" = 1, + "LastAdvanceId" = + '47000000-0000-0000-0000-000000000001', + "LastAdvanceSha256" = repeat('a', 64), + "LastRunId" = + '46000000-0000-0000-0000-000000000002', + "UpdatedAtUtc" = '2026-08-11T21:02:00Z', + "Version" = 3 + WHERE "Id" = + '44000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.Equal("P0001", forgedAdvanceDigest.SqlState); + + string correctAdvanceDigest = Sha256( + "2|45000000-0000-0000-0000-000000000001|null|" + + $"{firstOrdinal}|0|" + + "46000000-0000-0000-0000-000000000002|1|1|0|0|0|0|0|0|0"); + await ExecuteNonQueryAsync( + connectionString, + $$""" + UPDATE workspaces.workspace_staff_identity_anchor_sweep_checkpoints + SET "AfterOrdinal" = {{firstOrdinal}}, + "CycleScannedCount" = 1, + "CycleNoAnchorCount" = 1, + "LastAdvanceId" = + '47000000-0000-0000-0000-000000000001', + "LastAdvanceSha256" = '{{correctAdvanceDigest}}', + "LastRunId" = + '46000000-0000-0000-0000-000000000002', + "UpdatedAtUtc" = '2026-08-11T21:02:00Z', + "Version" = 3 + WHERE "Id" = '44000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false); + + string correctCompletionDigest = Sha256( + "3|45000000-0000-0000-0000-000000000001|" + + $"{firstOrdinal}|{cycleUpperOrdinal}|1|" + + "46000000-0000-0000-0000-000000000003|1|1|0|0|0|0|0|0|0"); + PostgresException inflatedCompletion = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + $$""" + UPDATE + workspaces.workspace_staff_identity_anchor_sweep_checkpoints + SET "CycleId" = NULL, + "CycleUpperOrdinal" = NULL, + "AfterOrdinal" = NULL, + "CycleStartedAtUtc" = NULL, + "CycleScannedCount" = 0, + "CycleNoAnchorCount" = 0, + "LastCompletedCycleId" = + '45000000-0000-0000-0000-000000000001', + "LastCompletedUpperOrdinal" = {{cycleUpperOrdinal}}, + "LastCompletedAtUtc" = '2026-08-11T21:03:00Z', + "LastCompletedScannedCount" = 3, + "LastCompletedNoAnchorCount" = 3, + "LastAdvanceId" = + '47000000-0000-0000-0000-000000000002', + "LastAdvanceSha256" = + '{{correctCompletionDigest}}', + "LastRunId" = + '46000000-0000-0000-0000-000000000003', + "UpdatedAtUtc" = '2026-08-11T21:03:00Z', + "Version" = 4 + WHERE "Id" = + '44000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.Equal("P0001", inflatedCompletion.SqlState); + + await ExecuteNonQueryAsync( + connectionString, + $$""" + UPDATE workspaces.workspace_staff_identity_anchor_sweep_checkpoints + SET "CycleId" = NULL, + "CycleUpperOrdinal" = NULL, + "AfterOrdinal" = NULL, + "CycleStartedAtUtc" = NULL, + "CycleScannedCount" = 0, + "CycleNoAnchorCount" = 0, + "LastCompletedCycleId" = + '45000000-0000-0000-0000-000000000001', + "LastCompletedUpperOrdinal" = {{cycleUpperOrdinal}}, + "LastCompletedAtUtc" = '2026-08-11T21:03:00Z', + "LastCompletedScannedCount" = 2, + "LastCompletedNoAnchorCount" = 2, + "LastAdvanceId" = + '47000000-0000-0000-0000-000000000002', + "LastAdvanceSha256" = '{{correctCompletionDigest}}', + "LastRunId" = + '46000000-0000-0000-0000-000000000003', + "UpdatedAtUtc" = '2026-08-11T21:03:00Z', + "Version" = 4 + WHERE "Id" = '44000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false); + + await ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO + workspaces.workspace_staff_identity_anchor_sweep_checkpoints ( + "Id", "ProtocolVersion", "CycleScannedCount", + "CycleNoAnchorCount", "CycleRemovedCount", + "CycleObservedCount", "CycleAlreadyObservedCount", + "CycleDeferredCount", "CycleConflictCount", + "CyclePassOneCommittedCount", + "CycleResolutionRecordConfirmedCount", + "LastCompletedScannedCount", "LastCompletedNoAnchorCount", + "LastCompletedRemovedCount", "LastCompletedObservedCount", + "LastCompletedAlreadyObservedCount", + "LastCompletedDeferredCount", "LastCompletedConflictCount", + "LastCompletedPassOneCommittedCount", + "LastCompletedResolutionRecordConfirmedCount", + "UpdatedAtUtc", "Version", "ScopeId") + VALUES ( + '44000000-0000-0000-0000-000000000002', 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, + '2026-08-11T21:00:00Z', 1, '{{OtherTenantId}}') + """).ConfigureAwait(false); + PostgresException forgedEmptyDigest = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + """ + UPDATE + workspaces.workspace_staff_identity_anchor_sweep_checkpoints + SET "LastCompletedCycleId" = + '48000000-0000-0000-0000-000000000001', + "LastCompletedAtUtc" = '2026-08-11T21:01:00Z', + "LastAdvanceId" = + '49000000-0000-0000-0000-000000000001', + "LastAdvanceSha256" = repeat('b', 64), + "LastRunId" = + '4a000000-0000-0000-0000-000000000001', + "UpdatedAtUtc" = '2026-08-11T21:01:00Z', + "Version" = 2 + WHERE "Id" = + '44000000-0000-0000-0000-000000000002' + """)).ConfigureAwait(false); + Assert.Equal("P0001", forgedEmptyDigest.SqlState); + + string correctEmptyDigest = Sha256( + "empty|48000000-0000-0000-0000-000000000001|" + + "4a000000-0000-0000-0000-000000000001"); + await ExecuteNonQueryAsync( + connectionString, + $$""" + UPDATE workspaces.workspace_staff_identity_anchor_sweep_checkpoints + SET "LastCompletedCycleId" = + '48000000-0000-0000-0000-000000000001', + "LastCompletedAtUtc" = '2026-08-11T21:01:00Z', + "LastAdvanceId" = + '49000000-0000-0000-0000-000000000001', + "LastAdvanceSha256" = '{{correctEmptyDigest}}', + "LastRunId" = + '4a000000-0000-0000-0000-000000000001', + "UpdatedAtUtc" = '2026-08-11T21:01:00Z', + "Version" = 2 + WHERE "Id" = '44000000-0000-0000-0000-000000000002' + """).ConfigureAwait(false); + + PostgresException unadmittedCheckpointDelete = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + """ + DELETE FROM + workspaces.workspace_staff_identity_anchor_sweep_checkpoints + WHERE "Id" = + '44000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.Equal("P0001", unadmittedCheckpointDelete.SqlState); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task + Checkpoint_high_water_and_empty_completion_are_bound_to_current_tenant_backlog() + { + const string emptyTenantId = + "10000000-0000-0000-0000-000000000003"; + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspaces_anchor_checkpoint_truth_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + await using (WorkspacesDbContext context = CreateDbContext( + connectionString, + TenantId)) + { + await context.Database.GetService() + .MigrateAsync(CurrentMigration) + .ConfigureAwait(false); + } + + await ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO workspaces.staff_onboarding_applications ( + "Id", "SourceKind", "SourceId", "SubjectId", + "VerifiedAccountEmail", "DisplayName", "Status", "Version", + "CreatedAtUtc", "LastChangedAtUtc", "ScopeId") + VALUES + ('81000000-0000-0000-0000-000000000001', 1, + '82000000-0000-0000-0000-000000000001', + 'subject:high-water-first', 'first@example.test', + 'High Water First', 1, 1, '2026-08-11T23:00:00Z', + '2026-08-11T23:00:00Z', '{{TenantId}}'), + ('81000000-0000-0000-0000-000000000002', 2, + '82000000-0000-0000-0000-000000000002', + 'subject:high-water-second', 'second@example.test', + 'High Water Second', 1, 1, '2026-08-11T23:00:00Z', + '2026-08-11T23:00:00Z', '{{TenantId}}'), + ('81000000-0000-0000-0000-000000000003', 1, + '82000000-0000-0000-0000-000000000003', + 'subject:backlog-present', 'backlog@example.test', + 'Backlog Present', 1, 1, '2026-08-11T23:00:00Z', + '2026-08-11T23:00:00Z', '{{OtherTenantId}}') + """).ConfigureAwait(false); + + long tenantMaximum = await ExecuteScalarAsync( + connectionString, + $$""" + SELECT max("IdentityAnchorSweepOrdinal") + FROM workspaces.staff_onboarding_applications + WHERE "ScopeId" = '{{TenantId}}' + """).ConfigureAwait(false); + long otherTenantMaximum = await ExecuteScalarAsync( + connectionString, + $$""" + SELECT max("IdentityAnchorSweepOrdinal") + FROM workspaces.staff_onboarding_applications + WHERE "ScopeId" = '{{OtherTenantId}}' + """).ConfigureAwait(false); + + PostgresException forgedInitialUpper = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO workspaces. + workspace_staff_identity_anchor_sweep_checkpoints ( + "Id", "ProtocolVersion", "CycleId", + "CycleUpperOrdinal", "AfterOrdinal", + "CycleStartedAtUtc", "CycleScannedCount", + "CycleNoAnchorCount", "CycleRemovedCount", + "CycleObservedCount", "CycleAlreadyObservedCount", + "CycleDeferredCount", "CycleConflictCount", + "CyclePassOneCommittedCount", + "CycleResolutionRecordConfirmedCount", + "LastCompletedScannedCount", + "LastCompletedNoAnchorCount", + "LastCompletedRemovedCount", + "LastCompletedObservedCount", + "LastCompletedAlreadyObservedCount", + "LastCompletedDeferredCount", + "LastCompletedConflictCount", + "LastCompletedPassOneCommittedCount", + "LastCompletedResolutionRecordConfirmedCount", + "LastRunId", "UpdatedAtUtc", "Version", "ScopeId") + VALUES ( + '83000000-0000-0000-0000-000000000001', 1, + '84000000-0000-0000-0000-000000000001', + {{tenantMaximum + 1}}, NULL, + '2026-08-11T23:01:00Z', 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, + '85000000-0000-0000-0000-000000000001', + '2026-08-11T23:01:00Z', 2, '{{TenantId}}') + """)).ConfigureAwait(false); + Assert.Equal("P0001", forgedInitialUpper.SqlState); + + await ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO workspaces. + workspace_staff_identity_anchor_sweep_checkpoints ( + "Id", "ProtocolVersion", "CycleId", "CycleUpperOrdinal", + "AfterOrdinal", "CycleStartedAtUtc", "CycleScannedCount", + "CycleNoAnchorCount", "CycleRemovedCount", + "CycleObservedCount", "CycleAlreadyObservedCount", + "CycleDeferredCount", "CycleConflictCount", + "CyclePassOneCommittedCount", + "CycleResolutionRecordConfirmedCount", + "LastCompletedScannedCount", "LastCompletedNoAnchorCount", + "LastCompletedRemovedCount", "LastCompletedObservedCount", + "LastCompletedAlreadyObservedCount", + "LastCompletedDeferredCount", "LastCompletedConflictCount", + "LastCompletedPassOneCommittedCount", + "LastCompletedResolutionRecordConfirmedCount", + "LastRunId", "UpdatedAtUtc", "Version", "ScopeId") + VALUES ( + '83000000-0000-0000-0000-000000000001', 1, + '84000000-0000-0000-0000-000000000001', {{tenantMaximum}}, + NULL, '2026-08-11T23:01:00Z', 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, + '85000000-0000-0000-0000-000000000001', + '2026-08-11T23:01:00Z', 2, '{{TenantId}}') + """).ConfigureAwait(false); + Assert.Equal( + tenantMaximum, + await ExecuteScalarAsync( + connectionString, + $$""" + SELECT "CycleUpperOrdinal" + FROM workspaces. + workspace_staff_identity_anchor_sweep_checkpoints + WHERE "ScopeId" = '{{TenantId}}' + """).ConfigureAwait(false)); + + await ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO workspaces. + workspace_staff_identity_anchor_sweep_checkpoints ( + "Id", "ProtocolVersion", "CycleScannedCount", + "CycleNoAnchorCount", "CycleRemovedCount", + "CycleObservedCount", "CycleAlreadyObservedCount", + "CycleDeferredCount", "CycleConflictCount", + "CyclePassOneCommittedCount", + "CycleResolutionRecordConfirmedCount", + "LastCompletedScannedCount", "LastCompletedNoAnchorCount", + "LastCompletedRemovedCount", "LastCompletedObservedCount", + "LastCompletedAlreadyObservedCount", + "LastCompletedDeferredCount", "LastCompletedConflictCount", + "LastCompletedPassOneCommittedCount", + "LastCompletedResolutionRecordConfirmedCount", + "UpdatedAtUtc", "Version", "ScopeId") + VALUES + ('83000000-0000-0000-0000-000000000002', 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + '2026-08-11T23:00:00Z', 1, '{{OtherTenantId}}'), + ('83000000-0000-0000-0000-000000000003', 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + '2026-08-11T23:00:00Z', 1, '{{emptyTenantId}}') + """).ConfigureAwait(false); + + const string blockedEmptyCycleId = + "86000000-0000-0000-0000-000000000001"; + const string blockedEmptyRunId = + "87000000-0000-0000-0000-000000000001"; + string blockedEmptyDigest = Sha256( + $"empty|{blockedEmptyCycleId}|{blockedEmptyRunId}"); + PostgresException emptyWithBacklog = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + $$""" + UPDATE workspaces. + workspace_staff_identity_anchor_sweep_checkpoints + SET "LastCompletedCycleId" = '{{blockedEmptyCycleId}}', + "LastCompletedAtUtc" = '2026-08-11T23:02:00Z', + "LastAdvanceId" = + '88000000-0000-0000-0000-000000000001', + "LastAdvanceSha256" = '{{blockedEmptyDigest}}', + "LastRunId" = '{{blockedEmptyRunId}}', + "UpdatedAtUtc" = '2026-08-11T23:02:00Z', + "Version" = 2 + WHERE "Id" = + '83000000-0000-0000-0000-000000000002' + """)).ConfigureAwait(false); + Assert.Equal("P0001", emptyWithBacklog.SqlState); + + PostgresException forgedBeginUpper = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + $$""" + UPDATE workspaces. + workspace_staff_identity_anchor_sweep_checkpoints + SET "CycleId" = + '86000000-0000-0000-0000-000000000002', + "CycleUpperOrdinal" = {{otherTenantMaximum + 1}}, + "CycleStartedAtUtc" = '2026-08-11T23:02:00Z', + "LastRunId" = + '87000000-0000-0000-0000-000000000002', + "UpdatedAtUtc" = '2026-08-11T23:02:00Z', + "Version" = 2 + WHERE "Id" = + '83000000-0000-0000-0000-000000000002' + """)).ConfigureAwait(false); + Assert.Equal("P0001", forgedBeginUpper.SqlState); + + await ExecuteNonQueryAsync( + connectionString, + $$""" + UPDATE workspaces. + workspace_staff_identity_anchor_sweep_checkpoints + SET "CycleId" = '86000000-0000-0000-0000-000000000002', + "CycleUpperOrdinal" = {{otherTenantMaximum}}, + "CycleStartedAtUtc" = '2026-08-11T23:02:00Z', + "LastRunId" = '87000000-0000-0000-0000-000000000002', + "UpdatedAtUtc" = '2026-08-11T23:02:00Z', + "Version" = 2 + WHERE "Id" = '83000000-0000-0000-0000-000000000002' + """).ConfigureAwait(false); + + const string trueEmptyCycleId = + "86000000-0000-0000-0000-000000000003"; + const string trueEmptyRunId = + "87000000-0000-0000-0000-000000000003"; + string trueEmptyDigest = Sha256( + $"empty|{trueEmptyCycleId}|{trueEmptyRunId}"); + await ExecuteNonQueryAsync( + connectionString, + $$""" + UPDATE workspaces. + workspace_staff_identity_anchor_sweep_checkpoints + SET "LastCompletedCycleId" = '{{trueEmptyCycleId}}', + "LastCompletedAtUtc" = '2026-08-11T23:02:00Z', + "LastAdvanceId" = + '88000000-0000-0000-0000-000000000003', + "LastAdvanceSha256" = '{{trueEmptyDigest}}', + "LastRunId" = '{{trueEmptyRunId}}', + "UpdatedAtUtc" = '2026-08-11T23:02:00Z', + "Version" = 2 + WHERE "Id" = '83000000-0000-0000-0000-000000000003' + """).ConfigureAwait(false); + Assert.Equal( + trueEmptyDigest, + await ExecuteScalarAsync( + connectionString, + $$""" + SELECT "LastAdvanceSha256" + FROM workspaces. + workspace_staff_identity_anchor_sweep_checkpoints + WHERE "ScopeId" = '{{emptyTenantId}}' + """).ConfigureAwait(false)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task + Destruction_deletes_require_exact_operation_scope_and_attempted_stage() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspaces_anchor_destroy_guard_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + DateTimeOffset createdAtUtc = new( + 2026, + 8, + 11, + 21, + 0, + 0, + TimeSpan.Zero); + Guid receiptId = + Guid.Parse("51000000-0000-0000-0000-000000000002"); + + await using (WorkspacesDbContext context = CreateDbContext( + connectionString, + TenantId)) + { + await context.Database.GetService() + .MigrateAsync(CurrentMigration) + .ConfigureAwait(false); + WorkspaceStaffOnboarding ordinary = + WorkspaceStaffOnboarding.Create( + Guid.Parse("51000000-0000-0000-0000-000000000001"), + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + Guid.Parse("52000000-0000-0000-0000-000000000001"), + "subject:destroy-ordinary", + "ordinary@example.test", + "Ordinary Applicant", + null, + null, + null, + null, + null, + null, + createdAtUtc).Value; + WorkspaceStaffOnboarding historical = + WorkspaceStaffOnboarding.Create( + Guid.Parse("51000000-0000-0000-0000-000000000002"), + TenantId, + WorkspaceStaffOnboardingSource.EnrollmentLink, + Guid.Parse("52000000-0000-0000-0000-000000000002"), + "subject:destroy-historical", + "historical@example.test", + "Historical Applicant", + null, + null, + null, + null, + null, + null, + createdAtUtc).Value; + context.StaffOnboardingApplications.AddRange(ordinary, historical); + await context.SaveChangesAsync().ConfigureAwait(false); + Assert.True(historical.ReviewHistoricalNoProvision( + receiptId, + createdAtUtc.AddMinutes(1)).IsSuccess); + context.StaffHistoricalNoProvisionReceipts.Add( + WorkspaceStaffHistoricalNoProvisionReceipt.Create( + receiptId, + TenantId, + Guid.Parse("53000000-0000-0000-0000-000000000001"), + historical.Id, + historical.SourceKind, + historical.SourceId, + 1, + WorkspaceStaffOnboardingState.Submitted, + historical.Version, + historical.Status, + 7, + 2, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .EnrollmentLinkDisabled, + new string('a', 64), + Guid.Parse("54000000-0000-0000-0000-000000000001"), + new string('b', 64), + "destroy-reviewer", + createdAtUtc.AddMinutes(1)).Value); + await context.SaveChangesAsync().ConfigureAwait(false); + } + + await using (WorkspacesDbContext sentinel = CreateDbContext( + connectionString, + OtherTenantId)) + { + sentinel.StaffOnboardingApplications.Add( + WorkspaceStaffOnboarding.Create( + Guid.Parse("51000000-0000-0000-0000-000000000003"), + OtherTenantId, + WorkspaceStaffOnboardingSource.Invitation, + Guid.Parse("52000000-0000-0000-0000-000000000003"), + "subject:cross-tenant-sentinel", + "sentinel@example.test", + "Sentinel Applicant", + null, + null, + null, + null, + null, + null, + createdAtUtc).Value); + await sentinel.SaveChangesAsync().ConfigureAwait(false); + } + + await ExecuteNonQueryAsync( + connectionString, + $$""" + INSERT INTO + workspaces.workspace_staff_identity_anchor_sweep_checkpoints ( + "Id", "ProtocolVersion", "CycleScannedCount", + "CycleNoAnchorCount", "CycleRemovedCount", + "CycleObservedCount", "CycleAlreadyObservedCount", + "CycleDeferredCount", "CycleConflictCount", + "CyclePassOneCommittedCount", + "CycleResolutionRecordConfirmedCount", + "LastCompletedScannedCount", "LastCompletedNoAnchorCount", + "LastCompletedRemovedCount", "LastCompletedObservedCount", + "LastCompletedAlreadyObservedCount", + "LastCompletedDeferredCount", "LastCompletedConflictCount", + "LastCompletedPassOneCommittedCount", + "LastCompletedResolutionRecordConfirmedCount", + "UpdatedAtUtc", "Version", "ScopeId") + VALUES ( + '55000000-0000-0000-0000-000000000001', 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, + '2026-08-11T21:02:00Z', 1, '{{TenantId}}'); + + INSERT INTO workspaces.workspace_termination_fences ( + "Id", "ProcessId", "CaseId", "ApprovalRevision", + "TerminationEpoch", "PolicyEvidenceSha256", "State", + "CreatedBy", "CreatedAtUtc", "LastChangedBy", + "LastChangedAtUtc", "Version", "ScopeId") + VALUES ( + '56000000-0000-0000-0000-000000000001', + '57000000-0000-0000-0000-000000000001', + '58000000-0000-0000-0000-000000000001', 1, + '59000000-0000-0000-0000-000000000001', repeat('c', 64), 2, + 'migration-test', '2026-08-11T21:03:00Z', 'migration-test', + '2026-08-11T21:04:00Z', 2, '{{TenantId}}'); + + INSERT INTO workspaces.tenant_destroy_operations ( + "OperationId", "ScopeId", "RequestSha256", "FenceId", + "SelectedFenceVersion", "ResultingFenceVersion", "BatchSize", + "Stage", "RemovedRecordCount", "CompletedBatchCount", + "ProofVersion", "RemovalProofSha256", "StartedAtUtc", + "UpdatedAtUtc", "ConcurrencyVersion") + VALUES ( + '5a000000-0000-0000-0000-000000000001', '{{TenantId}}', + repeat('d', 64), + '56000000-0000-0000-0000-000000000001', 2, 4, 500, 13, 0, + 0, 1, repeat('e', 64), '2026-08-11T21:05:00Z', + '2026-08-11T21:05:00Z', 1); + """).ConfigureAwait(false); + + PostgresException noApplicationAdmission = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + connectionString, + $$""" + DELETE FROM workspaces.staff_onboarding_applications + WHERE "ScopeId" = '{{TenantId}}' + """)).ConfigureAwait(false); + Assert.Equal("P0001", noApplicationAdmission.SqlState); + + PostgresException missingAttemptedStage = + await Assert.ThrowsAsync(() => + ExecuteDestroyDeleteAsync( + connectionString, + "5a000000-0000-0000-0000-000000000001", + attemptedStage: null, + $$""" + DELETE FROM workspaces.staff_onboarding_applications + WHERE "ScopeId" = '{{TenantId}}' + """)).ConfigureAwait(false); + Assert.Equal("P0001", missingAttemptedStage.SqlState); + + PostgresException wrongApplicationStage = + await Assert.ThrowsAsync(() => + ExecuteDestroyDeleteAsync( + connectionString, + "5a000000-0000-0000-0000-000000000001", + attemptedStage: 21, + $$""" + DELETE FROM workspaces.staff_onboarding_applications + WHERE "ScopeId" = '{{TenantId}}' + """)).ConfigureAwait(false); + Assert.Equal("P0001", wrongApplicationStage.SqlState); + + Assert.Equal( + 2, + await ExecuteDestroyDeleteAsync( + connectionString, + "5a000000-0000-0000-0000-000000000001", + attemptedStage: 13, + $$""" + DELETE FROM workspaces.staff_onboarding_applications + WHERE "ScopeId" = '{{TenantId}}' + """).ConfigureAwait(false)); + Assert.Equal( + 1, + await ExecuteScalarAsync( + connectionString, + $$""" + SELECT COUNT(*)::integer + FROM workspaces.staff_onboarding_applications + WHERE "ScopeId" = '{{OtherTenantId}}' + """).ConfigureAwait(false)); + + await ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.tenant_destroy_operations + SET "Stage" = 20 + WHERE "OperationId" = + '5a000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false); + Assert.Equal( + 1, + await ExecuteDestroyDeleteAsync( + connectionString, + "5a000000-0000-0000-0000-000000000001", + attemptedStage: 20, + """ + DELETE FROM + workspaces.workspace_staff_identity_anchor_sweep_checkpoints + WHERE "Id" = + '55000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false)); + + await ExecuteNonQueryAsync( + connectionString, + """ + UPDATE workspaces.tenant_destroy_operations + SET "Stage" = 21 + WHERE "OperationId" = + '5a000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false); + PostgresException wrongReceiptOperation = + await Assert.ThrowsAsync(() => + ExecuteDestroyDeleteAsync( + connectionString, + "5a000000-0000-0000-0000-000000000099", + attemptedStage: 21, + """ + DELETE FROM + workspaces.staff_historical_no_provision_receipts + WHERE "Id" = + '51000000-0000-0000-0000-000000000002' + """)).ConfigureAwait(false); + Assert.Equal("P0001", wrongReceiptOperation.SqlState); + Assert.Equal( + 1, + await ExecuteDestroyDeleteAsync( + connectionString, + "5a000000-0000-0000-0000-000000000001", + attemptedStage: 21, + """ + DELETE FROM + workspaces.staff_historical_no_provision_receipts + WHERE "Id" = + '51000000-0000-0000-0000-000000000002' + """).ConfigureAwait(false)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task + Canonical_receipt_round_trips_microsecond_time_and_utf16_reviewer() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspaces_anchor_receipt_migration_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + DateTimeOffset createdAtUtc = new( + 2026, + 8, + 11, + 20, + 30, + 0, + TimeSpan.Zero); + DateTimeOffset reviewedAtUtc = createdAtUtc + .AddMinutes(5) + .AddTicks(9); + Guid applicationId = + Guid.Parse("11000000-0000-0000-0000-000000000001"); + Guid sourceId = + Guid.Parse("12000000-0000-0000-0000-000000000001"); + Guid receiptId = + Guid.Parse("13000000-0000-0000-0000-000000000001"); + Guid operationId = + Guid.Parse("14000000-0000-0000-0000-000000000001"); + Guid manifestId = + Guid.Parse("15000000-0000-0000-0000-000000000001"); + const string reviewerId = "reviewer-🧭"; + + await using WorkspacesDbContext context = CreateDbContext( + postgreSql.GetConnectionString(), + TenantId); + await context.Database.GetService() + .MigrateAsync(CurrentMigration) + .ConfigureAwait(false); + + WorkspaceStaffOnboarding application = + WorkspaceStaffOnboarding.Create( + applicationId, + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + sourceId, + "subject:historical-no-provision", + "historical@example.test", + "Historical Applicant", + "Historical Legal Name", + "work@example.test", + "+1-555-0100", + "EMP-001", + "Operator", + "Operations", + createdAtUtc).Value; + context.StaffOnboardingApplications.Add(application); + await context.SaveChangesAsync().ConfigureAwait(false); + + Assert.True(application.ReviewHistoricalNoProvision( + receiptId, + reviewedAtUtc).IsSuccess); + WorkspaceStaffHistoricalNoProvisionReceipt receipt = + WorkspaceStaffHistoricalNoProvisionReceipt.Create( + receiptId, + TenantId, + operationId, + applicationId, + WorkspaceStaffOnboardingSource.Invitation, + sourceId, + expectedApplicationVersion: 1, + WorkspaceStaffOnboardingState.Submitted, + application.Version, + application.Status, + organizationsScopeRevision: 17, + organizationsSourceVersion: 4, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationRevoked, + new string('a', 64), + manifestId, + new string('b', 64), + reviewerId, + reviewedAtUtc).Value; + context.StaffHistoricalNoProvisionReceipts.Add(receipt); + await context.SaveChangesAsync().ConfigureAwait(false); + + context.ChangeTracker.Clear(); + WorkspaceStaffHistoricalNoProvisionReceipt persisted = + await context.StaffHistoricalNoProvisionReceipts + .SingleAsync(item => item.Id == receiptId) + .ConfigureAwait(false); + WorkspaceStaffOnboarding persistedApplication = + await context.StaffOnboardingApplications + .SingleAsync(item => item.Id == applicationId) + .ConfigureAwait(false); + + Assert.True(persisted.HasValidCanonicalProof()); + Assert.True(persisted.MatchesResult(persistedApplication)); + Assert.Equal(reviewerId, persisted.ReviewerId); + Assert.Equal( + CanonicalizePostgreSqlTimestamp(reviewedAtUtc), + persisted.ReviewedAtUtc); + Assert.Equal( + WorkspaceStaffHistoricalNoProvisionReceipt + .CreateSubjectPseudonym(receiptId), + persistedApplication.SubjectId); + + PostgresException divergence = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + postgreSql.GetConnectionString(), + """ + UPDATE workspaces.staff_onboarding_applications + SET "SubjectId" = 'subject:resurrected' + WHERE "Id" = + '11000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.Equal("P0001", divergence.SqlState); + + PostgresException receiptUpdate = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + postgreSql.GetConnectionString(), + """ + UPDATE workspaces.staff_historical_no_provision_receipts + SET "CanonicalSha256" = repeat('c', 64) + WHERE "Id" = + '13000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.Equal("P0001", receiptUpdate.SqlState); + + PostgresException receiptDelete = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + postgreSql.GetConnectionString(), + """ + DELETE FROM + workspaces.staff_historical_no_provision_receipts + WHERE "Id" = + '13000000-0000-0000-0000-000000000001' + """)).ConfigureAwait(false); + Assert.Equal("P0001", receiptDelete.SqlState); + + context.ChangeTracker.Clear(); + Guid corruptApplicationId = + Guid.Parse("16000000-0000-0000-0000-000000000001"); + Guid corruptSourceId = + Guid.Parse("17000000-0000-0000-0000-000000000001"); + Guid corruptReceiptId = + Guid.Parse("18000000-0000-0000-0000-000000000001"); + DateTimeOffset corruptReviewedAtUtc = + createdAtUtc.AddMinutes(10); + WorkspaceStaffOnboarding corruptApplication = + WorkspaceStaffOnboarding.Create( + corruptApplicationId, + TenantId, + WorkspaceStaffOnboardingSource.EnrollmentLink, + corruptSourceId, + "subject:corrupt-canonical-candidate", + "corrupt@example.test", + "Corrupt Candidate", + null, + null, + null, + null, + null, + null, + createdAtUtc).Value; + context.StaffOnboardingApplications.Add(corruptApplication); + await context.SaveChangesAsync().ConfigureAwait(false); + Assert.True(corruptApplication.ReviewHistoricalNoProvision( + corruptReceiptId, + corruptReviewedAtUtc).IsSuccess); + await context.SaveChangesAsync().ConfigureAwait(false); + + PostgresException corruptCanonical = + await Assert.ThrowsAsync(() => + ExecuteNonQueryAsync( + postgreSql.GetConnectionString(), + $$""" + INSERT INTO + workspaces.staff_historical_no_provision_receipts ( + "Id", "ContractVersion", "OperationId", + "ApplicationId", "SourceKind", "SourceId", + "ExpectedApplicationVersion", + "ExpectedApplicationStatus", "ResultApplicationVersion", + "ResultApplicationStatus", "OrganizationsScopeRevision", + "OrganizationsSourceVersion", "OrganizationsSourceStatus", + "StaffEvidenceSha256", "ExternalEvidenceManifestId", + "ExternalEvidenceSha256", "ReviewerId", "ReviewedAtUtc", + "CanonicalSha256", "ScopeId") + VALUES ( + '18000000-0000-0000-0000-000000000001', 1, + '19000000-0000-0000-0000-000000000001', + '16000000-0000-0000-0000-000000000001', 2, + '17000000-0000-0000-0000-000000000001', 1, 1, 2, 8, + 19, 2, 7, repeat('a', 64), + '1a000000-0000-0000-0000-000000000001', + repeat('b', 64), 'reviewer-corrupt', + '2026-08-11T20:40:00Z', repeat('c', 64), + '{{TenantId}}') + """)).ConfigureAwait(false); + Assert.Equal("P0001", corruptCanonical.SqlState); + Assert.Equal( + 0, + await ExecuteScalarAsync( + postgreSql.GetConnectionString(), + """ + SELECT COUNT(*)::integer + FROM workspaces.staff_historical_no_provision_receipts + WHERE "Id" = + '18000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false)); + Assert.Equal( + 1, + await ExecuteScalarAsync( + postgreSql.GetConnectionString(), + """ + SELECT COUNT(*)::integer + FROM workspaces.staff_onboarding_applications + WHERE "Id" = + '16000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task + Receipt_and_application_races_serialize_to_one_consistent_committed_side() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspaces_anchor_receipt_race_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + DateTimeOffset createdAtUtc = new( + 2026, + 8, + 11, + 22, + 0, + 0, + TimeSpan.Zero); + Guid firstApplicationId = + Guid.Parse("71000000-0000-0000-0000-000000000001"); + Guid firstReceiptId = + Guid.Parse("72000000-0000-0000-0000-000000000001"); + Guid secondApplicationId = + Guid.Parse("71000000-0000-0000-0000-000000000002"); + Guid secondReceiptId = + Guid.Parse("72000000-0000-0000-0000-000000000002"); + WorkspaceStaffHistoricalNoProvisionReceipt firstReceipt; + WorkspaceStaffHistoricalNoProvisionReceipt secondReceipt; + + await using (WorkspacesDbContext context = CreateDbContext( + connectionString, + TenantId)) + { + await context.Database.GetService() + .MigrateAsync(CurrentMigration) + .ConfigureAwait(false); + WorkspaceStaffOnboarding first = + WorkspaceStaffOnboarding.Create( + firstApplicationId, + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + Guid.Parse("73000000-0000-0000-0000-000000000001"), + "subject:receipt-race-first", + "first@example.test", + "First Race", + null, + null, + null, + null, + null, + null, + createdAtUtc).Value; + WorkspaceStaffOnboarding second = + WorkspaceStaffOnboarding.Create( + secondApplicationId, + TenantId, + WorkspaceStaffOnboardingSource.EnrollmentLink, + Guid.Parse("73000000-0000-0000-0000-000000000002"), + "subject:receipt-race-second", + "second@example.test", + "Second Race", + null, + null, + null, + null, + null, + null, + createdAtUtc).Value; + context.StaffOnboardingApplications.AddRange(first, second); + await context.SaveChangesAsync().ConfigureAwait(false); + Assert.True(first.ReviewHistoricalNoProvision( + firstReceiptId, + createdAtUtc.AddMinutes(1)).IsSuccess); + Assert.True(second.ReviewHistoricalNoProvision( + secondReceiptId, + createdAtUtc.AddMinutes(2)).IsSuccess); + await context.SaveChangesAsync().ConfigureAwait(false); + + firstReceipt = + WorkspaceStaffHistoricalNoProvisionReceipt.Create( + firstReceiptId, + TenantId, + Guid.Parse("74000000-0000-0000-0000-000000000001"), + first.Id, + first.SourceKind, + first.SourceId, + 1, + WorkspaceStaffOnboardingState.Submitted, + first.Version, + first.Status, + 31, + 4, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .InvitationRevoked, + new string('a', 64), + Guid.Parse("75000000-0000-0000-0000-000000000001"), + new string('b', 64), + "race-reviewer-first", + createdAtUtc.AddMinutes(1)).Value; + secondReceipt = + WorkspaceStaffHistoricalNoProvisionReceipt.Create( + secondReceiptId, + TenantId, + Guid.Parse("74000000-0000-0000-0000-000000000002"), + second.Id, + second.SourceKind, + second.SourceId, + 1, + WorkspaceStaffOnboardingState.Submitted, + second.Version, + second.Status, + 32, + 5, + WorkspaceStaffHistoricalNoProvisionAuthorityStatus + .EnrollmentLinkDisabled, + new string('c', 64), + Guid.Parse("75000000-0000-0000-0000-000000000002"), + new string('d', 64), + "race-reviewer-second", + createdAtUtc.AddMinutes(2)).Value; + } + + await using (NpgsqlConnection applicationWriter = + new(connectionString)) + await using (NpgsqlConnection receiptWriter = new(connectionString)) + { + await applicationWriter.OpenAsync().ConfigureAwait(false); + await receiptWriter.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction applicationTransaction = + await applicationWriter.BeginTransactionAsync() + .ConfigureAwait(false); + await using NpgsqlTransaction receiptTransaction = + await receiptWriter.BeginTransactionAsync() + .ConfigureAwait(false); + await ExecuteNonQueryAsync( + applicationWriter, + applicationTransaction, + """ + UPDATE workspaces.staff_onboarding_applications + SET "SubjectId" = 'subject:divergent-first' + WHERE "Id" = + '71000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false); + await InsertHistoricalReceiptAsync( + receiptWriter, + receiptTransaction, + firstReceipt).ConfigureAwait(false); + + Task receiptCommit = receiptTransaction.CommitAsync(); + Assert.True(await WaitForBackendLockAsync( + connectionString, + receiptWriter.ProcessID, + receiptCommit).ConfigureAwait(false)); + await applicationTransaction.CommitAsync().ConfigureAwait(false); + PostgresException rejectedReceipt = + await Assert.ThrowsAsync(async () => + await receiptCommit.ConfigureAwait(false)) + .ConfigureAwait(false); + Assert.Equal("P0001", rejectedReceipt.SqlState); + } + + await using (NpgsqlConnection receiptFirst = new(connectionString)) + await using (NpgsqlConnection applicationSecond = + new(connectionString)) + { + await receiptFirst.OpenAsync().ConfigureAwait(false); + await applicationSecond.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction receiptTransaction = + await receiptFirst.BeginTransactionAsync() + .ConfigureAwait(false); + await using NpgsqlTransaction applicationTransaction = + await applicationSecond.BeginTransactionAsync() + .ConfigureAwait(false); + await InsertHistoricalReceiptAsync( + receiptFirst, + receiptTransaction, + secondReceipt).ConfigureAwait(false); + await ExecuteNonQueryAsync( + receiptFirst, + receiptTransaction, + """ + SET CONSTRAINTS + workspaces. + "TR_staff_historical_no_provision_receipt_integrity" + IMMEDIATE + """).ConfigureAwait(false); + + Task applicationUpdate = ExecuteNonQueryAsync( + applicationSecond, + applicationTransaction, + """ + UPDATE workspaces.staff_onboarding_applications + SET "SubjectId" = 'subject:divergent-second' + WHERE "Id" = + '71000000-0000-0000-0000-000000000002' + """); + Assert.True(await WaitForBackendLockAsync( + connectionString, + applicationSecond.ProcessID, + applicationUpdate).ConfigureAwait(false)); + + await receiptTransaction.CommitAsync().ConfigureAwait(false); + Assert.Equal( + 1, + await applicationUpdate.ConfigureAwait(false)); + PostgresException rejectedApplication = + await Assert.ThrowsAsync(() => + applicationTransaction.CommitAsync()).ConfigureAwait(false); + Assert.Equal("P0001", rejectedApplication.SqlState); + } + + Assert.Equal( + 1, + await ExecuteScalarAsync( + connectionString, + """ + SELECT COUNT(*)::integer + FROM workspaces.staff_historical_no_provision_receipts + WHERE "Id" IN ( + '72000000-0000-0000-0000-000000000001', + '72000000-0000-0000-0000-000000000002') + """).ConfigureAwait(false)); + Assert.Equal( + "subject:divergent-first", + await ExecuteScalarAsync( + connectionString, + """ + SELECT "SubjectId" + FROM workspaces.staff_onboarding_applications + WHERE "Id" = + '71000000-0000-0000-0000-000000000001' + """).ConfigureAwait(false)); + Assert.Equal( + WorkspaceStaffHistoricalNoProvisionReceipt + .CreateSubjectPseudonym(secondReceiptId), + await ExecuteScalarAsync( + connectionString, + """ + SELECT "SubjectId" + FROM workspaces.staff_onboarding_applications + WHERE "Id" = + '71000000-0000-0000-0000-000000000002' + """).ConfigureAwait(false)); + } + + private static WorkspacesDbContext CreateDbContext( + string connectionString, + string scopeId) + { + DbContextOptions options = + new DbContextOptionsBuilder() + .UseNpgsql( + connectionString, + provider => provider + .MigrationsAssembly( + WorkspacesMigrations.PostgreSqlAssembly) + .MigrationsHistoryTable( + WorkspacesMigrations.HistoryTable, + WorkspacesMigrations.Schema)) + .Options; + return new(options, new TestScopeContext(scopeId)); + } + + private static DateTimeOffset CanonicalizePostgreSqlTimestamp( + DateTimeOffset value) + { + DateTimeOffset utc = value.ToUniversalTime(); + const long ticksPerMicrosecond = + TimeSpan.TicksPerMillisecond / 1000; + return new( + utc.Ticks - (utc.Ticks % ticksPerMicrosecond), + TimeSpan.Zero); + } + + private static async Task ExecuteNonQueryAsync( + string connectionString, + string sql) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new(sql, connection); + return await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + private static async Task ExecuteNonQueryAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + string sql) + { + await using NpgsqlCommand command = new(sql, connection, transaction); + return await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + private static async Task ExecuteScalarAsync( + string connectionString, + string sql) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new(sql, connection); + object? value = await command.ExecuteScalarAsync().ConfigureAwait(false); + return Assert.IsType(value); + } + + private static async Task ExecuteDestroyDeleteAsync( + string connectionString, + string operationId, + int? attemptedStage, + string deleteSql) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction transaction = + await connection.BeginTransactionAsync().ConfigureAwait(false); + await using (NpgsqlCommand operationSetting = new( + """ + SELECT set_config( + 'bunkfy.workspaces_tenant_destroy_operation_id', + @operationId, + true) + """, + connection, + transaction)) + { + operationSetting.Parameters.AddWithValue( + "operationId", + operationId); + _ = await operationSetting.ExecuteScalarAsync() + .ConfigureAwait(false); + } + + if (attemptedStage.HasValue) + { + await using NpgsqlCommand stageSetting = new( + """ + SELECT set_config( + 'bunkfy.workspaces_tenant_destroy_attempted_stage', + @attemptedStage, + true) + """, + connection, + transaction); + stageSetting.Parameters.AddWithValue( + "attemptedStage", + attemptedStage.Value.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + _ = await stageSetting.ExecuteScalarAsync().ConfigureAwait(false); + } + + await using NpgsqlCommand delete = new( + deleteSql, + connection, + transaction); + int affected = await delete.ExecuteNonQueryAsync().ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); + return affected; + } + + private static async Task InsertHistoricalReceiptAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + WorkspaceStaffHistoricalNoProvisionReceipt receipt) + { + await using NpgsqlCommand command = new( + """ + INSERT INTO workspaces.staff_historical_no_provision_receipts ( + "Id", "ContractVersion", "OperationId", "ApplicationId", + "SourceKind", "SourceId", "ExpectedApplicationVersion", + "ExpectedApplicationStatus", "ResultApplicationVersion", + "ResultApplicationStatus", "OrganizationsScopeRevision", + "OrganizationsSourceVersion", "OrganizationsSourceStatus", + "StaffEvidenceSha256", "ExternalEvidenceManifestId", + "ExternalEvidenceSha256", "ReviewerId", "ReviewedAtUtc", + "CanonicalSha256", "ScopeId") + VALUES ( + @id, @contractVersion, @operationId, @applicationId, + @sourceKind, @sourceId, @expectedApplicationVersion, + @expectedApplicationStatus, @resultApplicationVersion, + @resultApplicationStatus, @organizationsScopeRevision, + @organizationsSourceVersion, @organizationsSourceStatus, + @staffEvidenceSha256, @externalEvidenceManifestId, + @externalEvidenceSha256, @reviewerId, @reviewedAtUtc, + @canonicalSha256, @scopeId) + """, + connection, + transaction); + command.Parameters.AddWithValue("id", receipt.Id); + command.Parameters.AddWithValue( + "contractVersion", + receipt.ContractVersion); + command.Parameters.AddWithValue("operationId", receipt.OperationId); + command.Parameters.AddWithValue( + "applicationId", + receipt.ApplicationId); + command.Parameters.AddWithValue("sourceKind", (int)receipt.SourceKind); + command.Parameters.AddWithValue("sourceId", receipt.SourceId); + command.Parameters.AddWithValue( + "expectedApplicationVersion", + receipt.ExpectedApplicationVersion); + command.Parameters.AddWithValue( + "expectedApplicationStatus", + (int)receipt.ExpectedApplicationStatus); + command.Parameters.AddWithValue( + "resultApplicationVersion", + receipt.ResultApplicationVersion); + command.Parameters.AddWithValue( + "resultApplicationStatus", + (int)receipt.ResultApplicationStatus); + command.Parameters.AddWithValue( + "organizationsScopeRevision", + receipt.OrganizationsScopeRevision); + command.Parameters.AddWithValue( + "organizationsSourceVersion", + receipt.OrganizationsSourceVersion); + command.Parameters.AddWithValue( + "organizationsSourceStatus", + (int)receipt.OrganizationsSourceStatus); + command.Parameters.AddWithValue( + "staffEvidenceSha256", + receipt.StaffEvidenceSha256); + command.Parameters.AddWithValue( + "externalEvidenceManifestId", + receipt.ExternalEvidenceManifestId); + command.Parameters.AddWithValue( + "externalEvidenceSha256", + receipt.ExternalEvidenceSha256); + command.Parameters.AddWithValue("reviewerId", receipt.ReviewerId); + command.Parameters.AddWithValue("reviewedAtUtc", receipt.ReviewedAtUtc); + command.Parameters.AddWithValue( + "canonicalSha256", + receipt.CanonicalSha256); + command.Parameters.AddWithValue("scopeId", receipt.ScopeId); + _ = await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + private static async Task WaitForBackendLockAsync( + string connectionString, + int backendPid, + Task operation) + { + await using NpgsqlConnection observer = new(connectionString); + await observer.OpenAsync().ConfigureAwait(false); + for (int attempt = 0; attempt < 100; attempt++) + { + if (operation.IsCompleted) + { + return false; + } + + await using NpgsqlCommand command = new( + """ + SELECT EXISTS ( + SELECT 1 + FROM pg_stat_activity + WHERE pid = @backendPid + AND wait_event_type = 'Lock') + """, + observer); + command.Parameters.AddWithValue("backendPid", backendPid); + if (Assert.IsType( + await command.ExecuteScalarAsync().ConfigureAwait(false))) + { + return true; + } + + await Task.Delay(25).ConfigureAwait(false); + } + + return false; + } + + private static string Sha256(string value) => + Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(value))) + .ToLowerInvariant(); + + private sealed class TestScopeContext(string scopeId) : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId { get; } = scopeId; + } +} 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/WorkspaceStaffOnboardingIdentityAnchorLifecycleIntegrationTests.cs b/tests/Integration.Tests/Workspaces/WorkspaceStaffOnboardingIdentityAnchorLifecycleIntegrationTests.cs new file mode 100644 index 00000000..d8e6e63b --- /dev/null +++ b/tests/Integration.Tests/Workspaces/WorkspaceStaffOnboardingIdentityAnchorLifecycleIntegrationTests.cs @@ -0,0 +1,2046 @@ +namespace Integration.Tests; + +using System.Text.Json; +using BunkFy.Extensions.Workspaces; +using BunkFy.Modules.Staff.Application; +using BunkFy.Modules.Staff.Application.Commands; +using BunkFy.Modules.Staff.Application.Ports; +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Staff.Persistence; +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Application.Handlers; +using BunkFy.Modules.Workspaces.Contracts; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Domain.DataRights; +using BunkFy.Modules.Workspaces.Persistence; +using Gma.Framework.AccessControl; +using Gma.Framework.Application.Events.Infrastructure; +using Gma.Framework.Cqrs; +using Gma.Framework.Cqrs.Infrastructure; +using Gma.Framework.Cqrs.UnitOfWork; +using Gma.Framework.Messaging; +using Gma.Framework.Messaging.Infrastructure; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; +using Gma.Framework.Scoping; +using Gma.Framework.Scoping.Infrastructure; +using Gma.Modules.AccessControl.Contracts; +using Gma.Modules.Organizations.Contracts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; +using DomainResolutionDisposition = + BunkFy.Modules.Workspaces.Domain.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition; +using ContractResolutionDisposition = + BunkFy.Modules.Workspaces.Contracts.WorkspaceStaffOnboardingIdentityAnchorResolutionDisposition; +using DomainRestorationDisposition = + BunkFy.Modules.Workspaces.Domain.WorkspaceStaffAccessRestorationDisposition; + +public sealed class + WorkspaceStaffOnboardingIdentityAnchorLifecycleIntegrationTests +{ + private const string TenantId = + "b8000000-0000-0000-0000-000000000001"; + private const string SubjectId = "account-phase-a-anchor"; + private static readonly DateTimeOffset Now = new( + 2026, + 8, + 11, + 16, + 0, + 0, + TimeSpan.Zero); + private static readonly JsonSerializerOptions JsonOptions = + new(JsonSerializerDefaults.Web); + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Staff_commit_survives_Workspaces_rollback_and_two_pass_delivery_records_exact_resolution() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspace_anchor_phase_a_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + SequentialIdGenerator ids = new(); + PhaseAAccessState access = new(); + await using ServiceProvider services = CreateProvider( + postgreSql.GetConnectionString(), + ids, + access); + await MigrateAndInstallPhaseASchemaAsync(services) + .ConfigureAwait(false); + + Guid applicationId = Guid.NewGuid(); + Guid sourceId = Guid.NewGuid(); + await SeedSubmittedApplicationAsync( + services, + applicationId, + sourceId, + SubjectId, + access.ProfileId).ConfigureAwait(false); + + Guid rolledBackContinuationId; + StaffMemberDto provisioned; + Guid resolutionEventId; + using (IServiceScope rollbackScope = CreateTenantScope(services)) + { + ITransactionalUnitOfWork workspaces = GetTransactionalUnitOfWork( + rollbackScope.ServiceProvider, + WorkspacesMigrations.Schema); + await workspaces.BeginTransactionAsync().ConfigureAwait(false); + try + { + WorkspacesDbContext dbContext = rollbackScope.ServiceProvider + .GetRequiredService(); + WorkspaceStaffOnboarding application = await dbContext + .StaffOnboardingApplications.SingleAsync(candidate => + candidate.Id == applicationId).ConfigureAwait(false); + bool acquired = await rollbackScope.ServiceProvider + .GetRequiredService< + WorkspaceStaffOnboardingMutationCoordinator>() + .AcquireTrackedAsync( + application, + WorkspaceStaffOnboardingSourceLockMode.Read, + CancellationToken.None).ConfigureAwait(false); + Assert.True(acquired); + Assert.True(application.ObserveInvitationAccepted(Now) + .IsSuccess); + + Result staffResult = await SendAsync( + services, + new ProvisionStaffOnboardingCommand( + applicationId, + SubjectId, + "Phase A Applicant", + "Phase A Legal", + "phase-a@example.test", + null, + null, + "Front desk", + "Operations", + "integration:workspaces")).ConfigureAwait(false); + Assert.True(staffResult.IsSuccess, staffResult.Error.Code); + provisioned = staffResult.Value; + resolutionEventId = await ReadAnchorResolutionEventIdAsync( + services, + applicationId).ConfigureAwait(false); + + rolledBackContinuationId = ids.NewId(); + Assert.True(application.MarkStaffReady( + provisioned.StaffMemberId, + resolutionEventId, + rolledBackContinuationId, + Now.AddMinutes(1)).IsSuccess); + await workspaces.SaveChangesAsync().ConfigureAwait(false); + Assert.Contains( + dbContext.OutboxMessages.Local, + message => message.Id == rolledBackContinuationId); + } + finally + { + await workspaces.RollbackTransactionAsync( + CancellationToken.None).ConfigureAwait(false); + } + } + + using (IServiceScope afterRollback = CreateTenantScope(services)) + { + WorkspacesDbContext dbContext = afterRollback.ServiceProvider + .GetRequiredService(); + WorkspaceStaffOnboarding application = await dbContext + .StaffOnboardingApplications.AsNoTracking() + .SingleAsync(candidate => candidate.Id == applicationId) + .ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffOnboardingState.Submitted, + application.Status); + Assert.Null(application.StaffMemberId); + Assert.Null(application.IdentityAnchorExpectedResolutionEventId); + Assert.Null(application.IdentityAnchorContinuationEventId); + Assert.Equal("Phase A Applicant", application.DisplayName); + Assert.False(await dbContext.OutboxMessages.AsNoTracking() + .AnyAsync(message => message.Id == rolledBackContinuationId) + .ConfigureAwait(false)); + } + + StaffIdentityProvisioningAnchorCreatedIntegrationEvent anchorCreated = + await ReadOutboxEventAsync< + StaffIdentityProvisioningAnchorCreatedIntegrationEvent>( + services, + StaffModuleMetadata.Name, + applicationId).ConfigureAwait(false); + Assert.Equal(resolutionEventId, anchorCreated.ResolutionEventId); + + await DeliverWorkspacesAsync( + services, + anchorCreated, + WorkspacesModuleMetadata + .StaffOnboardingIdentityAnchorCreatedHandlerName) + .ConfigureAwait(false); + + Guid committedContinuationId; + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + continuation; + using (IServiceScope passOne = CreateTenantScope(services)) + { + WorkspacesDbContext dbContext = passOne.ServiceProvider + .GetRequiredService(); + WorkspaceStaffOnboarding application = await dbContext + .StaffOnboardingApplications.AsNoTracking() + .SingleAsync(candidate => candidate.Id == applicationId) + .ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffOnboardingState.StaffReady, + application.Status); + Assert.Equal(provisioned.StaffMemberId, application.StaffMemberId); + Assert.Equal( + resolutionEventId, + application.IdentityAnchorExpectedResolutionEventId); + committedContinuationId = Assert.IsType( + application.IdentityAnchorContinuationEventId); + Assert.NotEqual(rolledBackContinuationId, committedContinuationId); + Assert.Null(application.DisplayName); + Assert.Null(application.VerifiedAccountEmail); + Assert.Null(application.IdentityAnchorResolutionEventId); + Assert.Equal( + 1, + await dbContext.OutboxMessages.AsNoTracking().CountAsync( + message => message.Id == committedContinuationId) + .ConfigureAwait(false)); + continuation = await ReadOutboxEventAsync< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent>( + passOne.ServiceProvider, + WorkspacesModuleMetadata.Name, + committedContinuationId).ConfigureAwait(false); + } + + await DeliverWorkspacesAsync( + services, + continuation, + WorkspacesModuleMetadata + .StaffOnboardingIdentityAnchorContinuationHandlerName) + .ConfigureAwait(false); + + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent resolved; + long completedVersion; + using (IServiceScope passTwo = CreateTenantScope(services)) + { + WorkspacesDbContext dbContext = passTwo.ServiceProvider + .GetRequiredService(); + WorkspaceStaffOnboarding application = await dbContext + .StaffOnboardingApplications.AsNoTracking() + .SingleAsync(candidate => candidate.Id == applicationId) + .ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffOnboardingState.Completed, + application.Status); + Assert.Equal( + committedContinuationId, + application.IdentityAnchorContinuationEventId); + Assert.Equal( + resolutionEventId, + application.IdentityAnchorResolutionEventId); + Assert.Equal( + DomainResolutionDisposition + .CompletedRedacted, + application.IdentityAnchorResolutionDisposition); + Assert.Null(application.IdentityAnchorResolutionObservedAtUtc); + completedVersion = Assert.IsType( + application.IdentityAnchorResolutionApplicationVersion); + Assert.Equal(application.Version, completedVersion); + Assert.Equal(1, access.ProvisionCountFor(SubjectId)); + Assert.Equal( + 1, + await dbContext.OutboxMessages.AsNoTracking().CountAsync( + message => message.Id == resolutionEventId) + .ConfigureAwait(false)); + resolved = await ReadOutboxEventAsync< + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent>( + passTwo.ServiceProvider, + WorkspacesModuleMetadata.Name, + resolutionEventId).ConfigureAwait(false); + } + + await DeliverWorkspacesAsync( + services, + continuation, + WorkspacesModuleMetadata + .StaffOnboardingIdentityAnchorContinuationHandlerName) + .ConfigureAwait(false); + using (IServiceScope replay = CreateTenantScope(services)) + { + WorkspacesDbContext dbContext = replay.ServiceProvider + .GetRequiredService(); + Assert.Equal( + 1, + await dbContext.OutboxMessages.AsNoTracking().CountAsync( + message => message.Id == committedContinuationId) + .ConfigureAwait(false)); + Assert.Equal( + 1, + await dbContext.OutboxMessages.AsNoTracking().CountAsync( + message => message.Id == resolutionEventId) + .ConfigureAwait(false)); + } + + await DeliverStaffExtensionAsync(services, resolved) + .ConfigureAwait(false); + StaffWorkspaceOnboardingIdentityAnchorOutcome outcome = + await ReadStaffOutcomeAsync( + services, + applicationId, + SubjectId).ConfigureAwait(false); + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Resolved, + outcome.Status); + Assert.Equal(completedVersion, outcome.WorkspaceApplicationVersion); + Assert.Equal( + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + outcome.ResolutionDisposition); + Assert.Equal(resolutionEventId, outcome.ResolutionEventId); + + using IServiceScope finalScope = CreateTenantScope(services); + WorkspaceStaffOnboarding finalApplication = await finalScope + .ServiceProvider.GetRequiredService() + .StaffOnboardingApplications.AsNoTracking() + .SingleAsync(candidate => candidate.Id == applicationId) + .ConfigureAwait(false); + Assert.Null(finalApplication.IdentityAnchorResolutionObservedAtUtc); + + long activeVersion = await ReadStaffVersionAsync( + services, + provisioned.StaffMemberId).ConfigureAwait(false); + Result suspended = await SendAsync( + services, + new SuspendStaffMemberCommand( + Guid.NewGuid(), + provisioned.StaffMemberId, + "Phase A positive restoration proof", + activeVersion, + "integration:test")).ConfigureAwait(false); + Assert.True(suspended.IsSuccess, suspended.Error.Code); + Assert.Empty(access.TargetsFor(SubjectId)); + WorkspaceStaffAccessProcess suspension = await ReadAccessProcessAsync( + services, + provisioned.StaffMemberId, + suspended.Value.Version).ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffAccessTargetState.Suspended, + suspension.TargetState); + Assert.Equal( + WorkspaceStaffAccessProcessState.AwaitingStaffCommit, + suspension.State); + Assert.Single(suspension.ProfileSnapshots); + await DeliverStaffLifecycleAsync( + services, + provisioned.StaffMemberId, + suspended.Value.Version).ConfigureAwait(false); + + Result resumed = await SendAsync( + services, + new ResumeStaffMemberCommand( + Guid.NewGuid(), + provisioned.StaffMemberId, + "Phase A positive restoration proof", + suspended.Value.Version, + "integration:test")).ConfigureAwait(false); + Assert.True(resumed.IsSuccess, resumed.Error.Code); + WorkspaceStaffAccessProcess restoration = await ReadAccessProcessAsync( + services, + provisioned.StaffMemberId, + resumed.Value.Version).ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffAccessTargetState.Active, + restoration.TargetState); + Assert.Equal( + DomainRestorationDisposition.RestoreSnapshot, + restoration.RestorationDisposition); + Assert.Single(restoration.ProfileSnapshots); + await DeliverStaffLifecycleAsync( + services, + provisioned.StaffMemberId, + resumed.Value.Version).ConfigureAwait(false); + WorkspaceStaffAccessProcess completedRestoration = + await ReadAccessProcessAsync( + services, + provisioned.StaffMemberId, + resumed.Value.Version).ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffAccessProcessState.Completed, + completedRestoration.State); + Assert.Single(access.TargetsFor(SubjectId)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Unresolved_resume_is_blocked_then_negative_resolution_completes_suppressed_without_restore() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspace_anchor_resume_guard_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + SequentialIdGenerator ids = new(); + PhaseAAccessState access = new(); + await using ServiceProvider services = CreateProvider( + postgreSql.GetConnectionString(), + ids, + access); + await MigrateAndInstallPhaseASchemaAsync(services) + .ConfigureAwait(false); + + AnchoredPassOne seeded = await SeedAnchoredPassOneAsync( + services, + access, + SubjectId, + SubjectId).ConfigureAwait(false); + access.SeedTargets( + SubjectId, + new AccessProfileAssignmentTarget( + access.ProfileId, + WorkspaceAccessScopes.Create(TenantId))); + + long activeVersion = await ReadStaffVersionAsync( + services, + seeded.StaffMemberId).ConfigureAwait(false); + Result suspended = await SendAsync( + services, + new SuspendStaffMemberCommand( + Guid.NewGuid(), + seeded.StaffMemberId, + "Unresolved anchor resume guard", + activeVersion, + "integration:test")).ConfigureAwait(false); + Assert.True(suspended.IsSuccess, suspended.Error.Code); + Assert.Empty(access.TargetsFor(SubjectId)); + + Result blockedResume = await SendAsync( + services, + new ResumeStaffMemberCommand( + Guid.NewGuid(), + seeded.StaffMemberId, + "Must remain suspended until resolution", + suspended.Value.Version, + "integration:test")).ConfigureAwait(false); + Assert.Equal( + StaffApplicationErrors.IdentityAnchorResolutionRequired, + blockedResume.Error); + Assert.Equal( + StaffStatus.Suspended, + await ReadStaffStatusAsync( + services, + seeded.StaffMemberId).ConfigureAwait(false)); + Assert.False(await HasAccessProcessAsync( + services, + seeded.StaffMemberId, + suspended.Value.Version + 1).ConfigureAwait(false)); + await AssertRawResumeRejectedAsync( + postgreSql.GetConnectionString(), + seeded.StaffMemberId).ConfigureAwait(false); + + await DeliverStaffLifecycleAsync( + services, + seeded.StaffMemberId, + suspended.Value.Version).ConfigureAwait(false); + await DeliverWorkspacesAsync( + services, + seeded.Continuation, + WorkspacesModuleMetadata + .StaffOnboardingIdentityAnchorContinuationHandlerName) + .ConfigureAwait(false); + + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent + resolution = await ReadOutboxEventAsync< + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent>( + services, + WorkspacesModuleMetadata.Name, + seeded.ResolutionEventId).ConfigureAwait(false); + Assert.Equal( + ContractResolutionDisposition + .SupersededRedacted, + resolution.Disposition); + await DeliverStaffExtensionAsync(services, resolution) + .ConfigureAwait(false); + + int provisionsBeforeResume = access.ProvisionCountFor(SubjectId); + Result resumed = await SendAsync( + services, + new ResumeStaffMemberCommand( + Guid.NewGuid(), + seeded.StaffMemberId, + "Resolved negative onboarding remains suppressed", + suspended.Value.Version, + "integration:test")).ConfigureAwait(false); + Assert.True(resumed.IsSuccess, resumed.Error.Code); + WorkspaceStaffAccessProcess suppressed = await ReadAccessProcessAsync( + services, + seeded.StaffMemberId, + resumed.Value.Version).ConfigureAwait(false); + Assert.Equal( + DomainRestorationDisposition.Suppressed, + suppressed.RestorationDisposition); + Assert.Empty(suppressed.ProfileSnapshots); + Assert.Equal( + WorkspaceStaffAccessProcessState.AwaitingStaffCommit, + suppressed.State); + await DeliverStaffLifecycleAsync( + services, + seeded.StaffMemberId, + resumed.Value.Version).ConfigureAwait(false); + WorkspaceStaffAccessProcess completed = await ReadAccessProcessAsync( + services, + seeded.StaffMemberId, + resumed.Value.Version).ConfigureAwait(false); + Assert.Equal(WorkspaceStaffAccessProcessState.Completed, completed.State); + Assert.Empty(access.TargetsFor(SubjectId)); + Assert.Equal( + provisionsBeforeResume, + access.ProvisionCountFor(SubjectId)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Suspension_commit_serializes_pass_two_without_deadlock_or_precommit_grant() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspace_anchor_suspend_commit_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + SequentialIdGenerator ids = new(); + PhaseAAccessState access = new(); + BlockingLifecyclePolicy blocker = new( + StaffLifecyclePolicyDecision.Allowed); + await using ServiceProvider services = CreateProvider( + postgreSql.GetConnectionString(), + ids, + access, + blocker); + await MigrateAndInstallPhaseASchemaAsync(services) + .ConfigureAwait(false); + + ProvisionedAnchor seeded = await SeedProvisionedAnchorAsync( + services, + access, + SubjectId, + SubjectId).ConfigureAwait(false); + access.SeedTargets( + SubjectId, + new AccessProfileAssignmentTarget( + access.ProfileId, + WorkspaceAccessScopes.Create(TenantId))); + SuspendStaffMemberCommand suspend = new( + Guid.NewGuid(), + seeded.StaffMemberId, + "Serialize onboarding continuation against suspension", + seeded.StaffVersion, + "integration:test"); + Task> suspensionTask = + SendAsync(services, suspend); + await blocker.Entered.WaitAsync(TimeSpan.FromSeconds(10)) + .ConfigureAwait(false); + + Assert.Equal( + StaffStatus.Active, + await ReadStaffStatusAsync( + services, + seeded.StaffMemberId).ConfigureAwait(false)); + WorkspaceStaffAccessProcess open = await ReadAccessProcessAsync( + services, + seeded.StaffMemberId, + seeded.StaffVersion + 1).ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffAccessProcessState.AwaitingStaffCommit, + open.State); + Assert.Empty(access.TargetsFor(SubjectId)); + + await DeliverWorkspacesAsync( + services, + seeded.AnchorCreated, + WorkspacesModuleMetadata + .StaffOnboardingIdentityAnchorCreatedHandlerName) + .ConfigureAwait(false); + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + continuation = await ReadContinuationAsync( + services, + seeded.ApplicationId).ConfigureAwait(false); + WorkspaceStaffOnboarding passOne = await ReadApplicationAsync( + services, + seeded.ApplicationId).ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffOnboardingState.StaffReady, + passOne.Status); + Assert.Null(passOne.DisplayName); + Assert.Null(passOne.IdentityAnchorResolutionEventId); + Assert.Equal(0, access.ProvisionCountFor(SubjectId)); + + Task continuationTask = DeliverWorkspacesAsync( + services, + continuation, + WorkspacesModuleMetadata + .StaffOnboardingIdentityAnchorContinuationHandlerName); + Assert.NotSame( + continuationTask, + await Task.WhenAny( + continuationTask, + Task.Delay(TimeSpan.FromMilliseconds(300))) + .ConfigureAwait(false)); + + blocker.Release(); + Result suspended = + await suspensionTask.WaitAsync(TimeSpan.FromSeconds(10)) + .ConfigureAwait(false); + Assert.True(suspended.IsSuccess, suspended.Error.Code); + await Assert.ThrowsAsync(async () => + await continuationTask.ConfigureAwait(false)); + WorkspaceStaffOnboarding afterPending = await ReadApplicationAsync( + services, + seeded.ApplicationId).ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffOnboardingState.StaffReady, + afterPending.Status); + Assert.Null(afterPending.IdentityAnchorResolutionEventId); + Assert.Equal(0, access.ProvisionCountFor(SubjectId)); + + await DeliverStaffLifecycleAsync( + services, + seeded.StaffMemberId, + suspended.Value.Version).ConfigureAwait(false); + await DeliverWorkspacesAsync( + services, + continuation, + WorkspacesModuleMetadata + .StaffOnboardingIdentityAnchorContinuationHandlerName) + .ConfigureAwait(false); + WorkspaceStaffOnboarding terminal = await ReadApplicationAsync( + services, + seeded.ApplicationId).ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffOnboardingState.Superseded, + terminal.Status); + Assert.Equal( + DomainResolutionDisposition.SupersededRedacted, + terminal.IdentityAnchorResolutionDisposition); + Assert.Equal(0, access.ProvisionCountFor(SubjectId)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Aborted_Staff_transition_preserves_durable_denial_and_exact_retry_commits_the_saga() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspace_anchor_suspend_retry_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + SequentialIdGenerator ids = new(); + PhaseAAccessState access = new(); + BlockingLifecyclePolicy blocker = new( + StaffLifecyclePolicyDecision.RetryRequired); + await using ServiceProvider services = CreateProvider( + postgreSql.GetConnectionString(), + ids, + access, + blocker); + await MigrateAndInstallPhaseASchemaAsync(services) + .ConfigureAwait(false); + + ProvisionedAnchor seeded = await SeedProvisionedAnchorAsync( + services, + access, + SubjectId, + SubjectId).ConfigureAwait(false); + access.SeedTargets( + SubjectId, + new AccessProfileAssignmentTarget( + access.ProfileId, + WorkspaceAccessScopes.Create(TenantId))); + SuspendStaffMemberCommand suspend = new( + Guid.NewGuid(), + seeded.StaffMemberId, + "Retry exact suspended-access saga", + seeded.StaffVersion, + "integration:test"); + Task> firstAttempt = + SendAsync(services, suspend); + await blocker.Entered.WaitAsync(TimeSpan.FromSeconds(10)) + .ConfigureAwait(false); + + await DeliverWorkspacesAsync( + services, + seeded.AnchorCreated, + WorkspacesModuleMetadata + .StaffOnboardingIdentityAnchorCreatedHandlerName) + .ConfigureAwait(false); + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + continuation = await ReadContinuationAsync( + services, + seeded.ApplicationId).ConfigureAwait(false); + Task continuationTask = DeliverWorkspacesAsync( + services, + continuation, + WorkspacesModuleMetadata + .StaffOnboardingIdentityAnchorContinuationHandlerName); + Assert.NotSame( + continuationTask, + await Task.WhenAny( + continuationTask, + Task.Delay(TimeSpan.FromMilliseconds(300))) + .ConfigureAwait(false)); + + blocker.Release(); + Result aborted = + await firstAttempt.WaitAsync(TimeSpan.FromSeconds(10)) + .ConfigureAwait(false); + Assert.Equal( + StaffApplicationErrors.LifecycleCoordinationPending, + aborted.Error); + await Assert.ThrowsAsync(async () => + await continuationTask.ConfigureAwait(false)); + Assert.Equal( + StaffStatus.Active, + await ReadStaffStatusAsync( + services, + seeded.StaffMemberId).ConfigureAwait(false)); + WorkspaceStaffAccessProcess durableDenial = + await ReadAccessProcessAsync( + services, + seeded.StaffMemberId, + seeded.StaffVersion + 1).ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffAccessProcessState.AwaitingStaffCommit, + durableDenial.State); + Assert.Empty(access.TargetsFor(SubjectId)); + Assert.Equal( + 1, + access.MembershipTransitionCount( + SubjectId, + OrganizationMembershipStatus.Suspended)); + + Result retried = await SendAsync( + services, + suspend).ConfigureAwait(false); + Assert.True(retried.IsSuccess, retried.Error.Code); + Assert.Equal( + 1, + access.MembershipTransitionCount( + SubjectId, + OrganizationMembershipStatus.Suspended)); + await DeliverStaffLifecycleAsync( + services, + seeded.StaffMemberId, + retried.Value.Version).ConfigureAwait(false); + await DeliverWorkspacesAsync( + services, + continuation, + WorkspacesModuleMetadata + .StaffOnboardingIdentityAnchorContinuationHandlerName) + .ConfigureAwait(false); + WorkspaceStaffOnboarding terminal = await ReadApplicationAsync( + services, + seeded.ApplicationId).ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffOnboardingState.Superseded, + terminal.Status); + Assert.NotNull(terminal.IdentityAnchorResolutionEventId); + Assert.Equal(0, access.ProvisionCountFor(SubjectId)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Historical_subject_mismatch_denies_old_only_and_blocks_later_subject_change() + { + const string oldSubjectId = "account-phase-a-historical-old"; + const string currentSubjectId = "account-phase-a-current"; + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspace_anchor_mismatch_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + SequentialIdGenerator ids = new(); + PhaseAAccessState access = new(); + await using ServiceProvider services = CreateProvider( + postgreSql.GetConnectionString(), + ids, + access); + await MigrateAndInstallPhaseASchemaAsync(services) + .ConfigureAwait(false); + + ProvisionedAnchor seeded = await SeedProvisionedAnchorAsync( + services, + access, + oldSubjectId, + currentSubjectId).ConfigureAwait(false); + AccessProfileAssignmentTarget target = new( + access.ProfileId, + WorkspaceAccessScopes.Create(TenantId)); + access.SeedTargets(oldSubjectId, target); + access.SeedTargets(currentSubjectId, target); + + await DeliverWorkspacesAsync( + services, + seeded.AnchorCreated, + WorkspacesModuleMetadata + .StaffOnboardingIdentityAnchorCreatedHandlerName) + .ConfigureAwait(false); + WorkspaceStaffOnboarding mismatch = await ReadApplicationAsync( + services, + seeded.ApplicationId).ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffOnboardingState.Superseded, + mismatch.Status); + Assert.Equal( + DomainResolutionDisposition.SupersededRedacted, + mismatch.IdentityAnchorResolutionDisposition); + Assert.Empty(access.TargetsFor(oldSubjectId)); + Assert.Single(access.TargetsFor(currentSubjectId)); + + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent + mismatchResolution = await ReadOutboxEventAsync< + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent>( + services, + WorkspacesModuleMetadata.Name, + seeded.ResolutionEventId).ConfigureAwait(false); + await DeliverStaffExtensionAsync(services, mismatchResolution) + .ConfigureAwait(false); + + Result currentSuspension = + await SendAsync( + services, + new SuspendStaffMemberCommand( + Guid.NewGuid(), + seeded.StaffMemberId, + "Capture the legitimate current-subject snapshot", + await ReadStaffVersionAsync( + services, + seeded.StaffMemberId).ConfigureAwait(false), + "integration:test")).ConfigureAwait(false); + Assert.True( + currentSuspension.IsSuccess, + currentSuspension.Error.Code); + Assert.Empty(access.TargetsFor(currentSubjectId)); + await DeliverStaffLifecycleAsync( + services, + seeded.StaffMemberId, + currentSuspension.Value.Version).ConfigureAwait(false); + + Result currentResume = await SendAsync( + services, + new ResumeStaffMemberCommand( + Guid.NewGuid(), + seeded.StaffMemberId, + "Restore the legitimate current-subject snapshot", + currentSuspension.Value.Version, + "integration:test")).ConfigureAwait(false); + Assert.True(currentResume.IsSuccess, currentResume.Error.Code); + WorkspaceStaffAccessProcess currentRestoration = + await ReadAccessProcessAsync( + services, + seeded.StaffMemberId, + currentResume.Value.Version).ConfigureAwait(false); + Assert.Equal( + DomainRestorationDisposition.RestoreSnapshot, + currentRestoration.RestorationDisposition); + await DeliverStaffLifecycleAsync( + services, + seeded.StaffMemberId, + currentResume.Value.Version).ConfigureAwait(false); + Assert.Single(access.TargetsFor(currentSubjectId)); + Assert.Empty(access.TargetsFor(oldSubjectId)); + + Result relinkSuspension = + await SendAsync( + services, + new SuspendStaffMemberCommand( + Guid.NewGuid(), + seeded.StaffMemberId, + "Suspend before the governed identity relink", + currentResume.Value.Version, + "integration:test")).ConfigureAwait(false); + Assert.True( + relinkSuspension.IsSuccess, + relinkSuspension.Error.Code); + await DeliverStaffLifecycleAsync( + services, + seeded.StaffMemberId, + relinkSuspension.Value.Version).ConfigureAwait(false); + Assert.Empty(access.TargetsFor(currentSubjectId)); + + Guid unlinkOperationId = Guid.NewGuid(); + int outboxCountBeforeUnlink = await ReadStaffOutboxCountAsync(services) + .ConfigureAwait(false); + Result unlinked = await SendAsync( + services, + new SetStaffAuthSubjectCommand( + unlinkOperationId, + seeded.StaffMemberId, + null, + relinkSuspension.Value.Version, + "integration:test")).ConfigureAwait(false); + Assert.Equal( + StaffApplicationErrors.IdentityAnchorAccessClosureRequired, + unlinked.Error); + Assert.Equal( + currentSubjectId, + await ReadStaffAuthSubjectAsync( + services, + seeded.StaffMemberId).ConfigureAwait(false)); + await AssertStaffOperationAbsentAsync( + services, + unlinkOperationId).ConfigureAwait(false); + Assert.Equal( + outboxCountBeforeUnlink, + await ReadStaffOutboxCountAsync(services).ConfigureAwait(false)); + + Result currentResumeAgain = + await SendAsync( + services, + new ResumeStaffMemberCommand( + Guid.NewGuid(), + seeded.StaffMemberId, + "Restore the still-linked current subject", + relinkSuspension.Value.Version, + "integration:test")).ConfigureAwait(false); + Assert.True( + currentResumeAgain.IsSuccess, + currentResumeAgain.Error.Code); + WorkspaceStaffAccessProcess currentRestorationAgain = + await ReadAccessProcessAsync( + services, + seeded.StaffMemberId, + currentResumeAgain.Value.Version).ConfigureAwait(false); + Assert.Equal( + DomainRestorationDisposition.RestoreSnapshot, + currentRestorationAgain.RestorationDisposition); + await DeliverStaffLifecycleAsync( + services, + seeded.StaffMemberId, + currentResumeAgain.Value.Version).ConfigureAwait(false); + Assert.Empty(access.TargetsFor(oldSubjectId)); + Assert.Single(access.TargetsFor(currentSubjectId)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Resolved_anchor_blocks_subject_relink_and_preserves_existing_access() + { + const string oldSubjectId = "account-phase-a-resolved-old"; + const string newSubjectId = "account-phase-a-resolved-new"; + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_workspace_anchor_resolved_relink_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + SequentialIdGenerator ids = new(); + PhaseAAccessState access = new(); + await using ServiceProvider services = CreateProvider( + postgreSql.GetConnectionString(), + ids, + access); + await MigrateAndInstallPhaseASchemaAsync(services) + .ConfigureAwait(false); + + AnchoredPassOne seeded = await SeedAnchoredPassOneAsync( + services, + access, + oldSubjectId, + oldSubjectId).ConfigureAwait(false); + await DeliverWorkspacesAsync( + services, + seeded.Continuation, + WorkspacesModuleMetadata + .StaffOnboardingIdentityAnchorContinuationHandlerName) + .ConfigureAwait(false); + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent + resolution = await ReadOutboxEventAsync< + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent>( + services, + WorkspacesModuleMetadata.Name, + seeded.ResolutionEventId).ConfigureAwait(false); + await DeliverStaffExtensionAsync(services, resolution) + .ConfigureAwait(false); + await DeliverWorkspacesAsync( + services, + seeded.Continuation, + WorkspacesModuleMetadata + .StaffOnboardingIdentityAnchorContinuationHandlerName) + .ConfigureAwait(false); + + Assert.Single(access.TargetsFor(oldSubjectId)); + AccessProfileAssignmentTarget newSubjectTarget = new( + Guid.NewGuid(), + WorkspaceAccessScopes.Create(TenantId)); + access.SeedTargets(newSubjectId, newSubjectTarget); + await AssertRawAuthSubjectChangeRejectedAsync( + postgreSql.GetConnectionString(), + seeded.StaffMemberId, + null).ConfigureAwait(false); + Assert.Equal( + oldSubjectId, + await ReadStaffAuthSubjectAsync( + services, + seeded.StaffMemberId).ConfigureAwait(false)); + Assert.Single(access.TargetsFor(oldSubjectId)); + Assert.Equal( + [newSubjectTarget], + access.TargetsFor(newSubjectId)); + + Result suspended = await SendAsync( + services, + new SuspendStaffMemberCommand( + Guid.NewGuid(), + seeded.StaffMemberId, + "Prepare a governed account-link change", + await ReadStaffVersionAsync( + services, + seeded.StaffMemberId).ConfigureAwait(false), + "integration:test")).ConfigureAwait(false); + Assert.True(suspended.IsSuccess, suspended.Error.Code); + await DeliverStaffLifecycleAsync( + services, + seeded.StaffMemberId, + suspended.Value.Version).ConfigureAwait(false); + Assert.Empty(access.TargetsFor(oldSubjectId)); + + Guid unlinkOperationId = Guid.NewGuid(); + int outboxCountBeforeUnlink = await ReadStaffOutboxCountAsync(services) + .ConfigureAwait(false); + Result unlinked = await SendAsync( + services, + new SetStaffAuthSubjectCommand( + unlinkOperationId, + seeded.StaffMemberId, + null, + suspended.Value.Version, + "integration:test")).ConfigureAwait(false); + Assert.Equal( + StaffApplicationErrors.IdentityAnchorAccessClosureRequired, + unlinked.Error); + Assert.Equal( + oldSubjectId, + await ReadStaffAuthSubjectAsync( + services, + seeded.StaffMemberId).ConfigureAwait(false)); + await AssertStaffOperationAbsentAsync( + services, + unlinkOperationId).ConfigureAwait(false); + Assert.Equal( + outboxCountBeforeUnlink, + await ReadStaffOutboxCountAsync(services).ConfigureAwait(false)); + + Result resumed = await SendAsync( + services, + new ResumeStaffMemberCommand( + Guid.NewGuid(), + seeded.StaffMemberId, + "Keep the existing account link", + suspended.Value.Version, + "integration:test")).ConfigureAwait(false); + Assert.True(resumed.IsSuccess, resumed.Error.Code); + WorkspaceStaffAccessProcess restoration = await ReadAccessProcessAsync( + services, + seeded.StaffMemberId, + resumed.Value.Version).ConfigureAwait(false); + Assert.Equal( + DomainRestorationDisposition.RestoreSnapshot, + restoration.RestorationDisposition); + await DeliverStaffLifecycleAsync( + services, + seeded.StaffMemberId, + resumed.Value.Version).ConfigureAwait(false); + + Assert.Single(access.TargetsFor(oldSubjectId)); + Assert.Equal( + [newSubjectTarget], + access.TargetsFor(newSubjectId)); + } + + private static async Task SeedAnchoredPassOneAsync( + ServiceProvider services, + PhaseAAccessState access, + string applicationSubjectId, + string staffSubjectId) + { + ProvisionedAnchor provisioned = await SeedProvisionedAnchorAsync( + services, + access, + applicationSubjectId, + staffSubjectId).ConfigureAwait(false); + await DeliverWorkspacesAsync( + services, + provisioned.AnchorCreated, + WorkspacesModuleMetadata + .StaffOnboardingIdentityAnchorCreatedHandlerName) + .ConfigureAwait(false); + + using IServiceScope scope = CreateTenantScope(services); + WorkspaceStaffOnboarding application = await scope.ServiceProvider + .GetRequiredService() + .StaffOnboardingApplications.AsNoTracking() + .SingleAsync(candidate => candidate.Id == provisioned.ApplicationId) + .ConfigureAwait(false); + Guid continuationEventId = Assert.IsType( + application.IdentityAnchorContinuationEventId); + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + continuation = await ReadOutboxEventAsync< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent>( + scope.ServiceProvider, + WorkspacesModuleMetadata.Name, + continuationEventId).ConfigureAwait(false); + return new AnchoredPassOne( + provisioned.ApplicationId, + provisioned.StaffMemberId, + provisioned.ResolutionEventId, + continuation); + } + + private static async Task SeedProvisionedAnchorAsync( + ServiceProvider services, + PhaseAAccessState access, + string applicationSubjectId, + string staffSubjectId) + { + Guid applicationId = Guid.NewGuid(); + await SeedSubmittedApplicationAsync( + services, + applicationId, + Guid.NewGuid(), + applicationSubjectId, + access.ProfileId).ConfigureAwait(false); + Result provisioned = await SendAsync( + services, + new ProvisionStaffOnboardingCommand( + applicationId, + staffSubjectId, + "Phase A Applicant", + "Phase A Legal", + "phase-a@example.test", + null, + null, + "Front desk", + "Operations", + "integration:workspaces")).ConfigureAwait(false); + Assert.True(provisioned.IsSuccess, provisioned.Error.Code); + Guid resolutionEventId = await ReadAnchorResolutionEventIdAsync( + services, + applicationId).ConfigureAwait(false); + StaffIdentityProvisioningAnchorCreatedIntegrationEvent anchorCreated = + await ReadOutboxEventAsync< + StaffIdentityProvisioningAnchorCreatedIntegrationEvent>( + services, + StaffModuleMetadata.Name, + applicationId).ConfigureAwait(false); + return new ProvisionedAnchor( + applicationId, + provisioned.Value.StaffMemberId, + provisioned.Value.Version, + resolutionEventId, + anchorCreated); + } + + private static async Task SeedSubmittedApplicationAsync( + ServiceProvider services, + Guid applicationId, + Guid sourceId, + string subjectId, + Guid profileId) + { + using IServiceScope scope = CreateTenantScope(services); + WorkspacesDbContext dbContext = scope.ServiceProvider + .GetRequiredService(); + WorkspaceStaffOnboarding application = WorkspaceStaffOnboarding.Create( + applicationId, + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + sourceId, + subjectId, + "phase-a-account@example.test", + "Phase A Applicant", + "Phase A Legal", + "phase-a@example.test", + null, + null, + "Front desk", + "Operations", + Now).Value; + WorkspaceStaffAccessPlan plan = WorkspaceStaffAccessPlan.Create( + sourceId, + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + profileId, + WorkspaceAccessProfileSeeds.FrontDeskKey, + [], + "account-owner", + Now).Value; + Assert.True(plan.Activate(Now.AddSeconds(1)).IsSuccess); + WorkspaceStaffOnboardingProcessingRestrictionProjection projection = + WorkspaceStaffOnboardingProcessingRestrictionProjection.Create( + TenantId, + applicationId, + WorkspaceStaffOnboardingProcessingRestrictionContract + .CurrentVersion, + Now).Value; + dbContext.StaffOnboardingApplications.Add(application); + dbContext.StaffAccessPlans.Add(plan); + dbContext.StaffOnboardingProcessingRestrictionProjections.Add( + projection); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + + private static async Task ReadAnchorResolutionEventIdAsync( + ServiceProvider services, + Guid applicationId) + { + using IServiceScope scope = CreateTenantScope(services); + StaffIdentityProvisioningAnchor anchor = await scope.ServiceProvider + .GetRequiredService() + .IdentityProvisioningAnchors.AsNoTracking() + .SingleAsync(candidate => + candidate.SourceKind == + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding && + candidate.SourceId == applicationId) + .ConfigureAwait(false); + return Assert.IsType(anchor.ResolutionEventId); + } + + private static async Task ReadStaffVersionAsync( + ServiceProvider services, + Guid staffMemberId) + { + using IServiceScope scope = CreateTenantScope(services); + return await scope.ServiceProvider.GetRequiredService() + .StaffMembers.AsNoTracking() + .Where(candidate => candidate.Id == staffMemberId) + .Select(candidate => candidate.Version) + .SingleAsync().ConfigureAwait(false); + } + + private static async Task ReadStaffStatusAsync( + ServiceProvider services, + Guid staffMemberId) + { + using IServiceScope scope = CreateTenantScope(services); + return (StaffStatus)await scope.ServiceProvider + .GetRequiredService() + .StaffMembers.AsNoTracking() + .Where(candidate => candidate.Id == staffMemberId) + .Select(candidate => (int)candidate.Status) + .SingleAsync().ConfigureAwait(false); + } + + private static async Task ReadStaffAuthSubjectAsync( + ServiceProvider services, + Guid staffMemberId) + { + using IServiceScope scope = CreateTenantScope(services); + return await scope.ServiceProvider + .GetRequiredService() + .StaffMembers.AsNoTracking() + .Where(candidate => candidate.Id == staffMemberId) + .Select(candidate => candidate.AuthSubjectId) + .SingleAsync().ConfigureAwait(false); + } + + private static async Task ReadStaffOutboxCountAsync( + ServiceProvider services) + { + using IServiceScope scope = CreateTenantScope(services); + return await scope.ServiceProvider + .GetRequiredService() + .OutboxMessages.AsNoTracking() + .CountAsync().ConfigureAwait(false); + } + + private static async Task AssertStaffOperationAbsentAsync( + ServiceProvider services, + Guid operationId) + { + using IServiceScope scope = CreateTenantScope(services); + Assert.False(await scope.ServiceProvider + .GetRequiredService() + .MemberMutationOperations.AsNoTracking() + .AnyAsync(candidate => candidate.Id == operationId) + .ConfigureAwait(false)); + } + + private static async Task ReadApplicationAsync( + ServiceProvider services, + Guid applicationId) + { + using IServiceScope scope = CreateTenantScope(services); + return await scope.ServiceProvider + .GetRequiredService() + .StaffOnboardingApplications.AsNoTracking() + .SingleAsync(candidate => candidate.Id == applicationId) + .ConfigureAwait(false); + } + + private static async Task< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent> + ReadContinuationAsync( + ServiceProvider services, + Guid applicationId) + { + using IServiceScope scope = CreateTenantScope(services); + WorkspaceStaffOnboarding application = await scope.ServiceProvider + .GetRequiredService() + .StaffOnboardingApplications.AsNoTracking() + .SingleAsync(candidate => candidate.Id == applicationId) + .ConfigureAwait(false); + Guid continuationEventId = Assert.IsType( + application.IdentityAnchorContinuationEventId); + return await ReadOutboxEventAsync< + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent>( + scope.ServiceProvider, + WorkspacesModuleMetadata.Name, + continuationEventId).ConfigureAwait(false); + } + + private static async Task HasAccessProcessAsync( + ServiceProvider services, + Guid staffMemberId, + long targetStaffVersion) + { + using IServiceScope scope = CreateTenantScope(services); + return await scope.ServiceProvider + .GetRequiredService() + .StaffAccessProcesses.AsNoTracking() + .AnyAsync(candidate => + candidate.StaffMemberId == staffMemberId && + candidate.TargetStaffVersion == targetStaffVersion) + .ConfigureAwait(false); + } + + private static async Task AssertRawResumeRejectedAsync( + string connectionString, + Guid staffMemberId) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction transaction = await connection + .BeginTransactionAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new( + """ + UPDATE staff.staff_members + SET "Status" = 1, + "SuspendedAtUtc" = NULL, + "Version" = "Version" + 1 + WHERE "ScopeId" = @scope_id + AND "Id" = @staff_member_id; + """, + connection, + transaction); + command.Parameters.AddWithValue("scope_id", TenantId); + command.Parameters.AddWithValue("staff_member_id", staffMemberId); + PostgresException exception = await Assert.ThrowsAsync< + PostgresException>(() => command.ExecuteNonQueryAsync()); + Assert.Equal(PostgresErrorCodes.RaiseException, exception.SqlState); + await transaction.RollbackAsync().ConfigureAwait(false); + } + + private static async Task AssertRawAuthSubjectChangeRejectedAsync( + string connectionString, + Guid staffMemberId, + string? authSubjectId) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlTransaction transaction = await connection + .BeginTransactionAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new( + """ + UPDATE staff.staff_members + SET "AuthSubjectId" = @auth_subject_id, + "Version" = "Version" + 1 + WHERE "ScopeId" = @scope_id + AND "Id" = @staff_member_id; + """, + connection, + transaction); + command.Parameters.AddWithValue("scope_id", TenantId); + command.Parameters.AddWithValue("staff_member_id", staffMemberId); + command.Parameters.AddWithValue( + "auth_subject_id", + NpgsqlTypes.NpgsqlDbType.Text, + (object?)authSubjectId ?? DBNull.Value); + PostgresException exception = await Assert.ThrowsAsync< + PostgresException>(() => command.ExecuteNonQueryAsync()); + Assert.Equal(PostgresErrorCodes.RaiseException, exception.SqlState); + Assert.Contains( + "Workspace onboarding anchor requires access closure before Staff Auth subject change", + exception.MessageText, + StringComparison.Ordinal); + await transaction.RollbackAsync().ConfigureAwait(false); + } + + private static async Task + ReadAccessProcessAsync( + ServiceProvider services, + Guid staffMemberId, + long targetStaffVersion) + { + using IServiceScope scope = CreateTenantScope(services); + return await scope.ServiceProvider + .GetRequiredService() + .StaffAccessProcesses.AsNoTracking() + .Include(candidate => candidate.ProfileSnapshots) + .SingleAsync(candidate => + candidate.StaffMemberId == staffMemberId && + candidate.TargetStaffVersion == targetStaffVersion) + .ConfigureAwait(false); + } + + private static async Task DeliverStaffLifecycleAsync( + ServiceProvider services, + Guid staffMemberId, + long staffVersion) + { + StaffMemberLifecycleChangedIntegrationEvent integrationEvent = + await ReadLifecycleEventAsync( + services, + staffMemberId, + staffVersion).ConfigureAwait(false); + await DeliverWorkspacesAsync( + services, + integrationEvent, + WorkspacesModuleMetadata.StaffAccessLifecycleHandlerName) + .ConfigureAwait(false); + } + + private static async Task + ReadLifecycleEventAsync( + ServiceProvider services, + Guid staffMemberId, + long staffVersion) + { + using IServiceScope scope = CreateTenantScope(services); + OutboxMessage[] messages = await scope.ServiceProvider + .GetRequiredService() + .OutboxMessages.AsNoTracking() + .Where(message => message.EventType == + typeof(StaffMemberLifecycleChangedIntegrationEvent) + .FullName && + message.Version == + StaffMemberLifecycleChangedIntegrationEvent.EventVersion) + .ToArrayAsync().ConfigureAwait(false); + return Assert.Single(messages + .Select(message => JsonSerializer.Deserialize< + StaffMemberLifecycleChangedIntegrationEvent>( + message.Payload, + JsonOptions)) + .OfType(), + candidate => + candidate.StaffMemberId == staffMemberId && + candidate.StaffVersion == staffVersion); + } + + private static async Task< + StaffWorkspaceOnboardingIdentityAnchorOutcome> ReadStaffOutcomeAsync( + ServiceProvider services, + Guid applicationId, + string expectedSubjectId) + { + using IServiceScope scope = CreateTenantScope(services); + IReadOnlyList outcomes = + await scope.ServiceProvider.GetRequiredService< + IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader>() + .ReadAsync( + [new(applicationId, expectedSubjectId)], + CancellationToken.None).ConfigureAwait(false); + return Assert.Single(outcomes); + } + + private static async Task DeliverWorkspacesAsync( + ServiceProvider services, + TEvent integrationEvent, + string handlerName) + where TEvent : IntegrationEvent + { + using IServiceScope scope = CreateTenantScope(services); + ITransactionalUnitOfWork workspaces = GetTransactionalUnitOfWork( + scope.ServiceProvider, + WorkspacesMigrations.Schema); + await workspaces.BeginTransactionAsync().ConfigureAwait(false); + try + { + IIntegrationEventHandler handler = GetHandler( + scope.ServiceProvider, + WorkspacesModuleMetadata.Name, + handlerName); + await handler.HandleAsync(integrationEvent, CancellationToken.None) + .ConfigureAwait(false); + await workspaces.SaveChangesAsync().ConfigureAwait(false); + await workspaces.CommitTransactionAsync().ConfigureAwait(false); + } + catch + { + await workspaces.RollbackTransactionAsync(CancellationToken.None) + .ConfigureAwait(false); + throw; + } + } + + private static async Task DeliverStaffExtensionAsync( + ServiceProvider services, + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent + integrationEvent) + { + using IServiceScope scope = CreateTenantScope(services); + IIntegrationEventHandler< + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent> + handler = GetHandler< + WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent>( + scope.ServiceProvider, + StaffModuleMetadata.Name, + "bunkfy-workspace-staff-identity-anchor-resolution"); + await handler.HandleAsync(integrationEvent, CancellationToken.None) + .ConfigureAwait(false); + } + + private static IIntegrationEventHandler GetHandler( + IServiceProvider services, + string consumerModule, + string handlerName) + where TEvent : IntegrationEvent + { + IIntegrationEventSubscriptionRegistry subscriptions = services + .GetRequiredService(); + Type handlerType = subscriptions.Subscriptions.Single(subscription => + subscription.ConsumerModule == consumerModule && + subscription.HandlerName == handlerName).HandlerType; + return (IIntegrationEventHandler)services + .GetRequiredService(handlerType); + } + + private static async Task ReadOutboxEventAsync( + ServiceProvider services, + string moduleName, + Guid eventId) + where TEvent : IntegrationEvent + { + using IServiceScope scope = CreateTenantScope(services); + return await ReadOutboxEventAsync( + scope.ServiceProvider, + moduleName, + eventId).ConfigureAwait(false); + } + + private static async Task ReadOutboxEventAsync( + IServiceProvider services, + string moduleName, + Guid eventId) + where TEvent : IntegrationEvent + { + OutboxMessage message = string.Equals( + moduleName, + StaffModuleMetadata.Name, + StringComparison.Ordinal) + ? await services.GetRequiredService() + .OutboxMessages.AsNoTracking().SingleAsync(candidate => + candidate.Id == eventId).ConfigureAwait(false) + : await services.GetRequiredService() + .OutboxMessages.AsNoTracking().SingleAsync(candidate => + candidate.Id == eventId).ConfigureAwait(false); + return Assert.IsType(JsonSerializer.Deserialize( + message.Payload, + JsonOptions)); + } + + private static async Task> SendAsync( + ServiceProvider services, + ICommand command) + { + using IServiceScope scope = CreateTenantScope(services); + return await scope.ServiceProvider + .GetRequiredService() + .SendAsync(command, CancellationToken.None) + .ConfigureAwait(false); + } + + private static ITransactionalUnitOfWork GetTransactionalUnitOfWork( + IServiceProvider services, + string moduleName) => Assert.IsType( + services.GetServices() + .Single(unitOfWork => string.Equals( + unitOfWork.ModuleName, + moduleName, + StringComparison.Ordinal)), + exactMatch: false); + + private static IServiceScope CreateTenantScope( + ServiceProvider services) + { + IServiceScope scope = services.CreateScope(); + scope.ServiceProvider.GetRequiredService() + .SetScope(TenantId); + return scope; + } + + private static async Task MigrateAndInstallPhaseASchemaAsync( + ServiceProvider services) + { + using IServiceScope scope = CreateTenantScope(services); + await scope.ServiceProvider.GetRequiredService() + .Database.GetService().MigrateAsync() + .ConfigureAwait(false); + WorkspacesDbContext workspaces = scope.ServiceProvider + .GetRequiredService(); + await workspaces.Database.GetService().MigrateAsync( + "20260811044039_AddWorkspaceStaffDeferredClaimWithdrawals") + .ConfigureAwait(false); + await workspaces.Database.ExecuteSqlRawAsync( + PhaseASchemaSql).ConfigureAwait(false); + } + + private static ServiceProvider CreateProvider( + string connectionString, + SequentialIdGenerator ids, + PhaseAAccessState access, + BlockingLifecyclePolicy? blockingLifecycle = null) + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration["Persistence:Provider"] = "PostgreSql"; + builder.Configuration["ConnectionStrings:PostgreSql"] = + connectionString; + builder.Configuration["Scoping:Enabled"] = "true"; + builder.AddScopingInfrastructure(); + builder.AddApplicationEventsInfrastructure(); + builder.AddCqrsInfrastructure(); + builder.AddMessagingInfrastructure(); + builder.Services.AddSingleton(access); + builder.Services.AddSingleton(access); + builder.Services.AddSingleton(access); + builder.Services.AddSingleton(access); + builder.Services.AddSingleton( + AllowAllAuthorizationService.Instance); + builder.Services.AddStaffApplication(); + builder.AddStaffPersistence(); + builder.Services.AddWorkspacesApplication( + builder.Configuration, + "global"); + if (blockingLifecycle is not null) + { + builder.Services.AddSingleton( + blockingLifecycle); + } + builder.AddWorkspacesPersistence(); + builder.Services.AddBunkFyWorkspaces(options => + options.GlobalAuthScopeId = "global"); + builder.Services.Replace(ServiceDescriptor.Singleton( + ids)); + builder.Services.Replace(ServiceDescriptor.Singleton( + new AdvancingClock())); + return builder.Services.BuildServiceProvider( + new ServiceProviderOptions { ValidateScopes = true }); + } + + private sealed class SequentialIdGenerator : IIdGenerator + { + private long next; + + public Guid NewId() + { + long value = Interlocked.Increment(ref this.next); + return Guid.Parse( + $"70000000-0000-0000-0000-{value:x12}"); + } + } + + private sealed record AnchoredPassOne( + Guid ApplicationId, + Guid StaffMemberId, + Guid ResolutionEventId, + WorkspaceStaffOnboardingIdentityAnchorContinuationRequestedIntegrationEvent + Continuation); + + private sealed record ProvisionedAnchor( + Guid ApplicationId, + Guid StaffMemberId, + long StaffVersion, + Guid ResolutionEventId, + StaffIdentityProvisioningAnchorCreatedIntegrationEvent AnchorCreated); + + private sealed class BlockingLifecyclePolicy( + StaffLifecyclePolicyDecision firstDecision) + : IStaffLifecyclePolicy + { + private readonly TaskCompletionSource entered = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource released = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private int remaining = 1; + + public Task Entered => this.entered.Task; + + public void Release() => this.released.TrySetResult(); + + public async ValueTask PrepareAsync( + StaffLifecyclePolicyContext context, + CancellationToken cancellationToken = default) + { + if (context.Transition != StaffLifecycleTransition.Suspend || + Interlocked.Exchange(ref this.remaining, 0) == 0) + { + return StaffLifecyclePolicyDecision.Allowed; + } + + this.entered.TrySetResult(); + await this.released.Task.WaitAsync(cancellationToken) + .ConfigureAwait(false); + return firstDecision; + } + } + + private sealed class AdvancingClock : ISystemClock + { + private long ticks; + + public DateTimeOffset UtcNow => Now.AddMilliseconds( + Interlocked.Increment(ref this.ticks)); + } + + private sealed class AllowAllAuthorizationService + : IAccessAuthorizationService + { + public static AllowAllAuthorizationService Instance { get; } = new(); + + public Task AuthorizeAsync( + AccessRequirement requirement, + CancellationToken cancellationToken) => + Task.FromResult(AccessDecision.Allowed()); + } + + private sealed class PhaseAAccessState + : IAccessControlRoleProvisioner, + IAccessProfileProvisioner, + IScopedAccessProfileProvisioner, + IOrganizationMembershipLifecycle + { + private readonly Dictionary + assignments = new(StringComparer.Ordinal); + private readonly HashSet membershipMarkers = + new(StringComparer.Ordinal); + private readonly List provisions = []; + private readonly List<(string SubjectId, + OrganizationMembershipStatus Status)> membershipTransitions = []; + + public Guid ProfileId { get; } = + Guid.Parse("75000000-0000-0000-0000-000000000001"); + + public int ProvisionCountFor(string subjectId) => + this.provisions.Count(candidate => string.Equals( + candidate, + subjectId, + StringComparison.Ordinal)); + + public AccessProfileAssignmentTarget[] TargetsFor( + string subjectId) => + (this.assignments.GetValueOrDefault(subjectId) ?? []).ToArray(); + + public void SeedTargets( + string subjectId, + params AccessProfileAssignmentTarget[] targets) => + this.assignments[subjectId] = targets.ToArray(); + + public int MembershipTransitionCount( + string subjectId, + OrganizationMembershipStatus status) => + this.membershipTransitions.Count(candidate => + string.Equals( + candidate.SubjectId, + subjectId, + StringComparison.Ordinal) && + candidate.Status == status); + + public Task EnsureRoleAsync( + AccessControlRoleDefinition role, + CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task EnsureAssignmentAsync( + AccessSubject subject, + string roleName, + AccessScope scope, + CancellationToken cancellationToken = default) + { + if (string.Equals( + roleName, + WorkspaceAccessRoles.MembershipMarker, + StringComparison.Ordinal)) + { + this.membershipMarkers.Add(subject.Id); + } + + return Task.CompletedTask; + } + + public Task + RemoveAssignmentAsync( + AccessSubject subject, + string roleName, + AccessScope scope, + CancellationToken cancellationToken = default) + { + bool removed = string.Equals( + roleName, + WorkspaceAccessRoles.MembershipMarker, + StringComparison.Ordinal) && + this.membershipMarkers.Remove(subject.Id); + return Task.FromResult(removed + ? AccessControlAssignmentRemovalOutcome.Removed + : AccessControlAssignmentRemovalOutcome.NotFound); + } + + public Task HasAssignmentAsync( + AccessSubject subject, + string roleName, + AccessScope scope, + CancellationToken cancellationToken = default) => + Task.FromResult(string.Equals( + roleName, + WorkspaceAccessRoles.Owner, + StringComparison.Ordinal)); + + public Task> + ListAssignmentsAsync( + string roleName, + AccessScope scope, + int page, + int pageSize, + CancellationToken cancellationToken = default) => + Task.FromResult(new AccessControlPage< + AccessControlRoleAssignment>([], page, pageSize, false)); + + public Task EnsureProfileAsync( + AccessScope ownerScope, + AccessProfileDefinition definition, + AccessSubject actor, + CancellationToken cancellationToken = default) => + Task.FromResult(this.Profile(ownerScope, definition.Key)); + + public Task FindProfileByKeyAsync( + AccessScope ownerScope, + string key, + CancellationToken cancellationToken = default) => + Task.FromResult(this.Profile(ownerScope, key)); + + public Task GetSubjectAssignmentsAsync( + AccessSubject subject, + AccessScope ownerScope, + CancellationToken cancellationToken = default) => + Task.FromResult(new AccessProfileAssignmentSet( + subject, + ownerScope, + [])); + + public Task + ReconcileSubjectAssignmentsAsync( + AccessSubject subject, + AccessScope ownerScope, + IReadOnlyCollection profileIds, + AccessSubject actor, + CancellationToken cancellationToken = default) => + Task.FromResult(new AccessProfileAssignmentReconciliation( + subject, + ownerScope, + profileIds.ToArray(), + profileIds.Count, + 0)); + + public Task + GetSubjectScopedAssignmentsAsync( + AccessSubject subject, + AccessScope ownerScope, + CancellationToken cancellationToken = default) + { + AccessProfileAssignmentTarget[] targets = + this.assignments.GetValueOrDefault(subject.Id) ?? []; + return Task.FromResult(new ScopedAccessProfileAssignmentSet( + subject, + ownerScope, + targets.Select(target => new ScopedAccessProfileAssignment( + this.Profile(ownerScope, WorkspaceAccessProfileSeeds + .FrontDeskKey), + target.AssignmentScope)).ToArray())); + } + + public Task + ReconcileSubjectScopedAssignmentsAsync( + AccessSubject subject, + AccessScope ownerScope, + IReadOnlyCollection targets, + AccessSubject actor, + CancellationToken cancellationToken = default) + { + AccessProfileAssignmentTarget[] desired = targets + .Distinct() + .ToArray(); + this.assignments[subject.Id] = desired; + if (desired.Length > 0) + { + this.provisions.Add(subject.Id); + } + + return Task.FromResult( + new ScopedAccessProfileAssignmentReconciliation( + subject, + ownerScope, + desired, + desired.Length, + 0)); + } + + public Task EnsureStateAsync( + Guid organizationId, + string subjectId, + OrganizationMembershipStatus desiredStatus, + string actorId, + CancellationToken cancellationToken = default) + { + this.membershipTransitions.Add((subjectId, desiredStatus)); + return Task.FromResult(new OrganizationMembershipLifecycleResult( + OrganizationMembershipLifecycleOutcome.Changed, + null)); + } + + private AccessProfileDto Profile( + AccessScope ownerScope, + string key) => new( + this.ProfileId, + ownerScope.Value, + key, + "Front desk", + "Front desk operations.", + AccessProfileStatus.Active, + 1, + WorkspaceAccessProfileSeeds.FrontDesk.Permissions.ToArray(), + 0, + Now, + Now); + } + + private const string PhaseASchemaSql = + """ + ALTER TABLE workspaces.staff_onboarding_applications + DROP CONSTRAINT "CK_staff_onboarding_pending_profile"; + + ALTER TABLE workspaces.staff_onboarding_applications + ADD COLUMN "IdentityAnchorExpectedResolutionEventId" uuid NULL, + ADD COLUMN "IdentityAnchorContinuationEventId" uuid NULL, + ADD COLUMN "IdentityAnchorResolutionEventId" uuid NULL, + ADD COLUMN "IdentityAnchorResolutionStaffMemberId" uuid NULL, + ADD COLUMN "IdentityAnchorResolutionApplicationVersion" bigint NULL, + ADD COLUMN "IdentityAnchorResolutionDisposition" integer NULL, + ADD COLUMN "IdentityAnchorResolutionIntentAtUtc" timestamp with time zone NULL, + ADD COLUMN "IdentityAnchorResolutionObservedAtUtc" timestamp with time zone NULL, + ADD COLUMN "IdentityAnchorSweepOrdinal" bigint + GENERATED BY DEFAULT AS IDENTITY; + + CREATE UNIQUE INDEX "UX_workspaces_staff_onboarding_anchor_resolution_event" + ON workspaces.staff_onboarding_applications + ("IdentityAnchorExpectedResolutionEventId") + WHERE "IdentityAnchorExpectedResolutionEventId" IS NOT NULL; + CREATE UNIQUE INDEX "UX_workspaces_staff_onboarding_anchor_continuation_event" + ON workspaces.staff_onboarding_applications + ("IdentityAnchorContinuationEventId") + WHERE "IdentityAnchorContinuationEventId" IS NOT NULL; + CREATE UNIQUE INDEX + "IX_staff_onboarding_applications_ScopeId_IdentityAnchorSweepOrdinal" + ON workspaces.staff_onboarding_applications + ("ScopeId", "IdentityAnchorSweepOrdinal"); + + ALTER TABLE workspaces.staff_onboarding_applications + ADD CONSTRAINT "CK_staff_onboarding_pending_profile" + CHECK ("StaffMemberId" IS NOT NULL OR + "Status" IN (5, 7, 8, 9, 10) OR + ("VerifiedAccountEmail" IS NOT NULL AND + "DisplayName" IS NOT NULL)), + ADD CONSTRAINT "CK_staff_onboarding_anchor_bound_redaction" + CHECK ("StaffMemberId" IS NULL 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)), + ADD CONSTRAINT "CK_staff_onboarding_anchor_expected_resolution" + CHECK (("IdentityAnchorExpectedResolutionEventId" IS NULL OR + ("StaffMemberId" IS NOT NULL AND + "IdentityAnchorExpectedResolutionEventId" <> + '00000000-0000-0000-0000-000000000000'::uuid AND + "IdentityAnchorExpectedResolutionEventId" <> "Id")) AND + ("IdentityAnchorContinuationEventId" IS NULL OR + ("IdentityAnchorExpectedResolutionEventId" IS NOT NULL AND + "IdentityAnchorContinuationEventId" <> + '00000000-0000-0000-0000-000000000000'::uuid AND + "IdentityAnchorContinuationEventId" <> "Id" AND + "IdentityAnchorContinuationEventId" <> + "IdentityAnchorExpectedResolutionEventId"))), + ADD CONSTRAINT "CK_staff_onboarding_anchor_resolution_intent" + CHECK (("IdentityAnchorResolutionEventId" IS NULL AND + "IdentityAnchorResolutionStaffMemberId" IS NULL AND + "IdentityAnchorResolutionApplicationVersion" IS NULL AND + "IdentityAnchorResolutionDisposition" IS NULL AND + "IdentityAnchorResolutionIntentAtUtc" IS NULL) OR + ("IdentityAnchorResolutionEventId" IS NOT NULL AND + "StaffMemberId" IS NOT NULL AND + "IdentityAnchorResolutionStaffMemberId" IS NOT NULL AND + "IdentityAnchorResolutionApplicationVersion" > 0 AND + "IdentityAnchorResolutionApplicationVersion" <= "Version" AND + "IdentityAnchorResolutionDisposition" BETWEEN 1 AND 5 AND + "IdentityAnchorResolutionIntentAtUtc" IS NOT NULL)), + ADD CONSTRAINT "CK_staff_onboarding_anchor_resolution_coordinates" + CHECK ("IdentityAnchorResolutionEventId" IS NULL OR + ("IdentityAnchorExpectedResolutionEventId" IS NOT NULL AND + "IdentityAnchorResolutionEventId" = + "IdentityAnchorExpectedResolutionEventId" AND + "StaffMemberId" IS NOT NULL AND + "IdentityAnchorResolutionStaffMemberId" = "StaffMemberId")), + ADD CONSTRAINT "CK_staff_onboarding_anchor_resolution_terminal" + CHECK ("IdentityAnchorResolutionEventId" IS NULL OR + (("Status" = 5 AND "IdentityAnchorResolutionDisposition" = 1) OR + ("Status" = 7 AND "IdentityAnchorResolutionDisposition" = 2) OR + ("Status" = 8 AND "IdentityAnchorResolutionDisposition" = 3) OR + ("Status" = 9 AND "IdentityAnchorResolutionDisposition" = 4) OR + ("Status" = 10 AND "IdentityAnchorResolutionDisposition" = 5))), + ADD CONSTRAINT "CK_staff_onboarding_anchor_resolution_observation" + CHECK ("IdentityAnchorResolutionObservedAtUtc" IS NULL OR + ("IdentityAnchorResolutionEventId" IS NOT NULL AND + "IdentityAnchorResolutionObservedAtUtc" >= + "IdentityAnchorResolutionIntentAtUtc")), + ADD CONSTRAINT "CK_staff_onboarding_identity_anchor_sweep_ordinal" + CHECK ("IdentityAnchorSweepOrdinal" > 0); + + ALTER TABLE workspaces.staff_access_processes + ADD COLUMN "RestorationDisposition" integer NULL; + UPDATE workspaces.staff_access_processes + SET "RestorationDisposition" = CASE + WHEN "TargetState" = 1 THEN 2 ELSE 1 END; + ALTER TABLE workspaces.staff_access_processes + ALTER COLUMN "RestorationDisposition" SET NOT NULL, + ADD CONSTRAINT "CK_staff_access_process_restoration_disposition" + CHECK (("TargetState" = 1 AND + "RestorationDisposition" IN (2, 3)) OR + ("TargetState" IN (2, 3) AND + "RestorationDisposition" = 1)); + """; +} diff --git a/tests/Integration.Tests/Workspaces/WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFenceIntegrationTests.cs b/tests/Integration.Tests/Workspaces/WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFenceIntegrationTests.cs new file mode 100644 index 00000000..e204b28a --- /dev/null +++ b/tests/Integration.Tests/Workspaces/WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFenceIntegrationTests.cs @@ -0,0 +1,390 @@ +namespace Integration.Tests; + +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Persistence; +using BunkFy.Modules.Workspaces.Persistence.Repositories; +using Gma.Framework.Scoping; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging.Abstractions; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class + WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFenceIntegrationTests +{ + private const string TenantId = + "ad000000-0000-0000-0000-000000000001"; + private const string OtherTenantId = + "ad000000-0000-0000-0000-000000000002"; + private const string SubjectId = "subject:anchor-mutation-fence"; + private const string OtherSubjectId = + "subject:anchor-mutation-fence-sentinel"; + private const string MutatedSubjectId = + "subject:anchor-mutation-fence-mutated"; + private static readonly Guid OtherSubjectApplicationId = + CreateOrderedId(900); + private static readonly Guid OtherTenantApplicationId = + CreateOrderedId(901); + private static readonly DateTimeOffset Now = + new(2026, 8, 11, 20, 0, 0, TimeSpan.Zero); + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Exact_absent_pages_permit_the_guarded_subject_mutation() + { + await using PostgreSqlContainer postgreSql = + await StartPostgreSqlAsync( + "bunkfy_anchor_subject_mutation_permit") + .ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + await CreateSchemaAndSeedAsync(connectionString) + .ConfigureAwait(false); + RecordingOutcomeReader outcomes = new(SecondPageMode.Absent); + + GuardedMutationResult result = await RunGuardedMutationAsync( + connectionString, + TenantId, + outcomes) + .ConfigureAwait(false); + + Assert.True(result.Allowed); + Assert.Equal(501, result.MutatedCount); + Assert.Equal([500, 1], outcomes.BatchSizes); + Assert.Equal(501, outcomes.RequestedApplicationIds.Count); + Assert.Equal( + 501, + outcomes.RequestedApplicationIds.Distinct().Count()); + Assert.DoesNotContain( + OtherSubjectApplicationId, + outcomes.RequestedApplicationIds); + Assert.DoesNotContain( + OtherTenantApplicationId, + outcomes.RequestedApplicationIds); + Assert.All( + outcomes.ExpectedSubjects, + subject => Assert.Equal(SubjectId, subject)); + await AssertPersistedSubjectsAsync( + connectionString, + expectedOriginalCount: 0, + expectedMutatedCount: 501) + .ConfigureAwait(false); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Second_page_unresolved_blocks_before_caller_mutation() + { + await AssertSecondPageBlocksAsync( + "bunkfy_anchor_subject_mutation_unresolved", + SecondPageMode.Unresolved) + .ConfigureAwait(false); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Second_page_malformed_blocks_before_caller_mutation() + { + await AssertSecondPageBlocksAsync( + "bunkfy_anchor_subject_mutation_malformed", + SecondPageMode.Malformed) + .ConfigureAwait(false); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Active_scope_mismatch_fails_before_staff_read() + { + await using PostgreSqlContainer postgreSql = + await StartPostgreSqlAsync( + "bunkfy_anchor_subject_mutation_scope_mismatch") + .ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + await CreateSchemaAndSeedAsync(connectionString) + .ConfigureAwait(false); + RecordingOutcomeReader outcomes = new(SecondPageMode.Absent); + + GuardedMutationResult result = await RunGuardedMutationAsync( + connectionString, + OtherTenantId, + outcomes) + .ConfigureAwait(false); + + Assert.False(result.Allowed); + Assert.Equal(0, result.MutatedCount); + Assert.Empty(outcomes.BatchSizes); + Assert.Empty(outcomes.RequestedApplicationIds); + await AssertPersistedSubjectsAsync( + connectionString, + expectedOriginalCount: 501, + expectedMutatedCount: 0) + .ConfigureAwait(false); + } + + private static async Task AssertSecondPageBlocksAsync( + string databaseName, + SecondPageMode mode) + { + await using PostgreSqlContainer postgreSql = + await StartPostgreSqlAsync(databaseName).ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + await CreateSchemaAndSeedAsync(connectionString) + .ConfigureAwait(false); + RecordingOutcomeReader outcomes = new(mode); + + GuardedMutationResult result = await RunGuardedMutationAsync( + connectionString, + TenantId, + outcomes) + .ConfigureAwait(false); + + Assert.False(result.Allowed); + Assert.Equal(0, result.MutatedCount); + Assert.Equal([500, 1], outcomes.BatchSizes); + Assert.Equal(501, outcomes.RequestedApplicationIds.Count); + await AssertPersistedSubjectsAsync( + connectionString, + expectedOriginalCount: 501, + expectedMutatedCount: 0) + .ConfigureAwait(false); + } + + private static async Task RunGuardedMutationAsync( + string connectionString, + string requestedTenantId, + RecordingOutcomeReader outcomes) + { + await using WorkspacesDbContext database = + CreateDbContext(connectionString, TenantId); + await using IDbContextTransaction transaction = await database + .Database.BeginTransactionAsync() + .ConfigureAwait(false); + await new WorkspaceCrossGraphMutationLock(database) + .AcquireAsync(CancellationToken.None) + .ConfigureAwait(false); + WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence fence = + new( + database, + outcomes, + new TestScopeContext(TenantId), + NullLogger< + WorkspaceStaffOnboardingIdentityAnchorSubjectMutationFence> + .Instance); + + bool allowed = await fence.CanMutateAsync( + requestedTenantId, + SubjectId, + CancellationToken.None) + .ConfigureAwait(false); + int mutatedCount = 0; + if (allowed) + { + mutatedCount = await database.StaffOnboardingApplications + .Where(application => + application.ScopeId == TenantId && + application.SubjectId == SubjectId) + .ExecuteUpdateAsync(setters => setters.SetProperty( + application => application.SubjectId, + MutatedSubjectId)) + .ConfigureAwait(false); + } + + await transaction.CommitAsync().ConfigureAwait(false); + return new(allowed, mutatedCount); + } + + private static async Task AssertPersistedSubjectsAsync( + string connectionString, + int expectedOriginalCount, + int expectedMutatedCount) + { + await using (WorkspacesDbContext tenant = + CreateDbContext(connectionString, TenantId)) + { + Assert.Equal( + expectedOriginalCount, + await tenant.StaffOnboardingApplications + .AsNoTracking() + .CountAsync(application => + application.SubjectId == SubjectId) + .ConfigureAwait(false)); + Assert.Equal( + expectedMutatedCount, + await tenant.StaffOnboardingApplications + .AsNoTracking() + .CountAsync(application => + application.SubjectId == MutatedSubjectId) + .ConfigureAwait(false)); + WorkspaceStaffOnboarding otherSubject = await tenant + .StaffOnboardingApplications + .AsNoTracking() + .SingleAsync(application => + application.Id == OtherSubjectApplicationId) + .ConfigureAwait(false); + Assert.Equal(OtherSubjectId, otherSubject.SubjectId); + } + + await using WorkspacesDbContext otherTenant = + CreateDbContext(connectionString, OtherTenantId); + WorkspaceStaffOnboarding otherTenantSentinel = await otherTenant + .StaffOnboardingApplications + .AsNoTracking() + .SingleAsync(application => + application.Id == OtherTenantApplicationId) + .ConfigureAwait(false); + Assert.Equal(SubjectId, otherTenantSentinel.SubjectId); + } + + private static async Task CreateSchemaAndSeedAsync( + string connectionString) + { + await using (WorkspacesDbContext tenant = + CreateDbContext(connectionString, TenantId)) + { + await tenant.Database.EnsureCreatedAsync().ConfigureAwait(false); + for (int index = 1; index <= 501; index++) + { + tenant.StaffOnboardingApplications.Add(CreateApplication( + CreateOrderedId(index), + TenantId, + SubjectId)); + } + + tenant.StaffOnboardingApplications.Add(CreateApplication( + OtherSubjectApplicationId, + TenantId, + OtherSubjectId)); + await tenant.SaveChangesAsync().ConfigureAwait(false); + } + + await using WorkspacesDbContext otherTenant = + CreateDbContext(connectionString, OtherTenantId); + otherTenant.StaffOnboardingApplications.Add(CreateApplication( + OtherTenantApplicationId, + OtherTenantId, + SubjectId)); + await otherTenant.SaveChangesAsync().ConfigureAwait(false); + } + + private static WorkspaceStaffOnboarding CreateApplication( + Guid applicationId, + string tenantId, + string subjectId) => WorkspaceStaffOnboarding.Create( + applicationId, + tenantId, + WorkspaceStaffOnboardingSource.Invitation, + Guid.NewGuid(), + subjectId, + $"{applicationId:N}@example.test", + "Anchor mutation fence applicant", + legalName: null, + workEmail: null, + workPhone: null, + employeeNumber: null, + jobTitle: null, + department: null, + Now).Value; + + private static WorkspacesDbContext CreateDbContext( + string connectionString, + string tenantId) => new( + new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .Options, + new TestScopeContext(tenantId)); + + private static async Task StartPostgreSqlAsync( + string databaseName) + { + PostgreSqlContainer postgreSql = new PostgreSqlBuilder( + "postgres:16-alpine") + .WithDatabase(databaseName) + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + return postgreSql; + } + + private static Guid CreateOrderedId(int value) => Guid.Parse( + $"00000000-0000-0000-0000-{value:D12}"); + + private sealed class RecordingOutcomeReader(SecondPageMode mode) + : IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + { + public List BatchSizes { get; } = []; + public List RequestedApplicationIds { get; } = []; + public List ExpectedSubjects { get; } = []; + + public Task> ReadAsync( + IReadOnlyList< + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest> + requests, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + this.BatchSizes.Add(requests.Count); + this.RequestedApplicationIds.AddRange( + requests.Select(request => request.ApplicationId)); + this.ExpectedSubjects.AddRange( + requests.Select(request => request.ExpectedAuthSubjectId)); + bool secondPage = this.BatchSizes.Count == 2; + if (secondPage && mode == SecondPageMode.Malformed) + { + return Task.FromResult>([]); + } + + return Task.FromResult>( + requests.Select(request => + secondPage && mode == SecondPageMode.Unresolved + ? CreateUnresolved(request.ApplicationId) + : CreateAbsent(request.ApplicationId)) + .ToArray()); + } + + private static StaffWorkspaceOnboardingIdentityAnchorOutcome + CreateAbsent(Guid applicationId) => new( + applicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Absent, + StaffMemberId: null, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Unknown, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Unknown, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + ResolutionEventId: null); + + private static StaffWorkspaceOnboardingIdentityAnchorOutcome + CreateUnresolved(Guid applicationId) => new( + applicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved, + Guid.Parse("ad000000-0000-0000-0000-000000000010"), + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact, + WorkspaceApplicationVersion: 1, + ResolutionDisposition: null, + Guid.Parse("ad000000-0000-0000-0000-000000000011")); + } + + private sealed class TestScopeContext(string tenantId) : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId { get; } = tenantId; + } + + private sealed record GuardedMutationResult( + bool Allowed, + int MutatedCount); + + private enum SecondPageMode + { + Absent, + Unresolved, + Malformed + } +} diff --git a/tests/Integration.Tests/Workspaces/WorkspaceStaffOnboardingSerializedReadBoundaryIntegrationTests.cs b/tests/Integration.Tests/Workspaces/WorkspaceStaffOnboardingSerializedReadBoundaryIntegrationTests.cs new file mode 100644 index 00000000..d107fee8 --- /dev/null +++ b/tests/Integration.Tests/Workspaces/WorkspaceStaffOnboardingSerializedReadBoundaryIntegrationTests.cs @@ -0,0 +1,508 @@ +namespace Integration.Tests; + +using BunkFy.Modules.DataRights.Contracts; +using BunkFy.Modules.Staff.Contracts; +using BunkFy.Modules.Workspaces.Application; +using BunkFy.Modules.Workspaces.Application.Handlers; +using BunkFy.Modules.Workspaces.Application.Queries; +using BunkFy.Modules.Workspaces.Contracts; +using BunkFy.Modules.Workspaces.Domain; +using BunkFy.Modules.Workspaces.Domain.DataRights; +using BunkFy.Modules.Workspaces.Domain.Termination; +using BunkFy.Modules.Workspaces.Persistence; +using BunkFy.Modules.Workspaces.Persistence.Repositories; +using Gma.Framework.Runtime.Time; +using Gma.Framework.Results; +using Gma.Framework.Scoping; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging.Abstractions; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class + WorkspaceStaffOnboardingSerializedReadBoundaryIntegrationTests +{ + private const string TenantId = + "ac000000-0000-0000-0000-000000000001"; + private const string SubjectId = "subject:serialized-profile-read"; + private static readonly Guid ApplicationId = + Guid.Parse("ac000000-0000-0000-0000-000000000002"); + private static readonly Guid SourceId = + Guid.Parse("ac000000-0000-0000-0000-000000000003"); + private static readonly DateTimeOffset Now = + new(2026, 8, 11, 15, 0, 0, TimeSpan.Zero); + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Successful_boundary_callback_is_conclusively_rolled_back() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_profile_read_boundary_rollback") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + await CreateSchemaAndSeedAsync(connectionString).ConfigureAwait(false); + + await using (WorkspacesDbContext dbContext = + CreateDbContext(connectionString)) + { + WorkspaceStaffOnboardingMutationCoordinator mutations = new( + new WorkspaceStaffOnboardingOperationLock(dbContext), + new WorkspaceStaffOnboardingRepository(dbContext)); + WorkspaceStaffOnboardingSerializedReadBoundary boundary = new( + dbContext); + + Result result = await boundary.RunAsync( + async cancellationToken => + { + WorkspaceStaffOnboardingMutationLease lease = + await mutations.AcquireExistingAsync( + ApplicationId, + WorkspaceStaffOnboardingSourceLockMode.Read, + requireOperational: true, + cancellationToken).ConfigureAwait(false); + WorkspaceStaffOnboarding application = + Assert.IsType( + lease.Application); + Assert.True(application.UpdateSubmission( + application.VerifiedAccountEmail!, + "Must roll back", + application.LegalName, + application.WorkEmail, + application.WorkPhone, + application.EmployeeNumber, + application.JobTitle, + application.Department, + Now.AddMinutes(1)).IsSuccess); + await dbContext.SaveChangesAsync(cancellationToken) + .ConfigureAwait(false); + return Result.Success(application.Version); + }, + CancellationToken.None).ConfigureAwait(false); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal(2, result.Value); + Assert.Null(dbContext.Database.CurrentTransaction); + } + + await using WorkspacesDbContext verification = + CreateDbContext(connectionString); + WorkspaceStaffOnboarding persisted = await verification + .StaffOnboardingApplications.AsNoTracking() + .SingleAsync(application => application.Id == ApplicationId) + .ConfigureAwait(false); + Assert.Equal("Applicant Profile", persisted.DisplayName); + Assert.Equal(1, persisted.Version); + Assert.Empty(await verification.OutboxMessages.AsNoTracking() + .ToArrayAsync().ConfigureAwait(false)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Get_waits_for_application_lock_before_reading_staff_outcome() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_profile_read_boundary_lock") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + await CreateSchemaAndSeedAsync(connectionString).ConfigureAwait(false); + + await using WorkspacesDbContext holder = + CreateDbContext(connectionString); + await using IDbContextTransaction holderTransaction = await holder + .Database.BeginTransactionAsync().ConfigureAwait(false); + WorkspaceStaffOnboardingMutationCoordinator holderMutations = new( + new WorkspaceStaffOnboardingOperationLock(holder), + new WorkspaceStaffOnboardingRepository(holder)); + WorkspaceStaffOnboardingMutationLease held = + await holderMutations.AcquireExistingAsync( + ApplicationId, + WorkspaceStaffOnboardingSourceLockMode.Read, + requireOperational: true, + CancellationToken.None).ConfigureAwait(false); + Assert.NotNull(held.Application); + + await using WorkspacesDbContext reader = + CreateDbContext(connectionString); + MutableOutcomeReader outcomes = new(); + TestScopeContext scope = new(); + GetOwnWorkspaceStaffOnboardingQueryHandler handler = new( + new WorkspaceStaffOnboardingMutationCoordinator( + new WorkspaceStaffOnboardingOperationLock(reader), + new WorkspaceStaffOnboardingRepository(reader)), + new WorkspaceStaffOnboardingSerializedReadBoundary(reader), + outcomes, + new WorkspaceOperationalAdmissionEvaluator( + new WorkspaceTerminationFenceRepository(reader), + scope, + NullLogger.Instance), + scope); + + Task> pending = + handler.HandleAsync( + new GetOwnWorkspaceStaffOnboardingQuery( + WorkspaceStaffOnboardingSourceKind.EnrollmentLink, + SourceId, + SubjectId), + CancellationToken.None); + await Task.Delay(TimeSpan.FromMilliseconds(300)).ConfigureAwait(false); + Assert.False(outcomes.ReadStarted.Task.IsCompleted); + + outcomes.Outcome = new StaffWorkspaceOnboardingIdentityAnchorOutcome( + ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved, + Guid.Parse("ac000000-0000-0000-0000-000000000004"), + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + Guid.Parse("ac000000-0000-0000-0000-000000000005")); + await holderTransaction.RollbackAsync(CancellationToken.None) + .ConfigureAwait(false); + + Result result = await pending.WaitAsync( + TimeSpan.FromSeconds(10)).ConfigureAwait(false); + Assert.Equal( + WorkspaceStaffOnboardingApplicationErrors + .ProfileMutationAuthorityUnavailable, + result.Error); + Assert.True(outcomes.ReadStarted.Task.IsCompleted); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Data_rights_export_waits_for_application_lock_and_writes_nothing_for_unresolved_anchor() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_profile_export_boundary_lock") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + await CreateSchemaAndSeedAsync(connectionString).ConfigureAwait(false); + + await using WorkspacesDbContext holder = + CreateDbContext(connectionString); + await using IDbContextTransaction holderTransaction = await holder + .Database.BeginTransactionAsync().ConfigureAwait(false); + WorkspaceStaffOnboardingMutationCoordinator holderMutations = new( + new WorkspaceStaffOnboardingOperationLock(holder), + new WorkspaceStaffOnboardingRepository(holder)); + WorkspaceStaffOnboardingMutationLease held = + await holderMutations.AcquireExistingAsync( + ApplicationId, + WorkspaceStaffOnboardingSourceLockMode.Read, + requireOperational: true, + CancellationToken.None).ConfigureAwait(false); + Assert.NotNull(held.Application); + + await using WorkspacesDbContext reader = + CreateDbContext(connectionString); + MutableOutcomeReader outcomes = new() + { + Outcome = new StaffWorkspaceOnboardingIdentityAnchorOutcome( + ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus.Unresolved, + Guid.Parse("ac000000-0000-0000-0000-000000000004"), + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle.Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch.Exact, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + Guid.Parse("ac000000-0000-0000-0000-000000000005")) + }; + TestScopeContext scope = new(); + WorkspacesDataRightsExportContributor contributor = new( + reader, + scope, + new WorkspaceStaffOnboardingSerializedReadBoundary(reader), + new WorkspaceStaffOnboardingOperationLock(reader), + new WorkspaceStaffOnboardingRepository(reader), + outcomes, + NullLogger.Instance); + CollectingSink sink = new(); + + Task pending = contributor.ExportAsync( + new DataRightsSubjectExportRequest( + TenantId, + DataRightsCaseType.StaffRights, + PropertyId: null, + new DataRightsSubjectCoordinate( + WorkspacesDataRightsCoordinates.Owner, + WorkspacesDataRightsCoordinates.StaffOnboardingRecordType, + ApplicationId, + RecordVersion: 1)), + sink, + CancellationToken.None); + await Task.Delay(TimeSpan.FromMilliseconds(300)).ConfigureAwait(false); + Assert.False(outcomes.ReadStarted.Task.IsCompleted); + Assert.Empty(sink.Records); + + await holderTransaction.RollbackAsync(CancellationToken.None) + .ConfigureAwait(false); + + DataRightsSubjectExportResult result = await pending.WaitAsync( + TimeSpan.FromSeconds(10)).ConfigureAwait(false); + Assert.Equal( + DataRightsSubjectExportStatus.ScopeUnavailable, + result.Status); + Assert.True(outcomes.ReadStarted.Task.IsCompleted); + Assert.Empty(sink.Records); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Tenant_export_preflights_more_than_one_staff_page_before_any_sink_write() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_tenant_export_anchor_preflight") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + await CreateSchemaAndSeedAsync(connectionString).ConfigureAwait(false); + + await using WorkspacesDbContext dbContext = + CreateDbContext(connectionString); + for (int index = 1; index <= 501; index++) + { + dbContext.StaffOnboardingApplications.Add( + WorkspaceStaffOnboarding.Create( + Guid.Parse($"ad000000-0000-0000-0000-{index:D12}"), + TenantId, + WorkspaceStaffOnboardingSource.Invitation, + Guid.Parse($"ae000000-0000-0000-0000-{index:D12}"), + $"subject:tenant-export:{index}", + $"verified-{index}@example.test", + $"Applicant {index}", + legalName: null, + workEmail: null, + workPhone: null, + employeeNumber: null, + jobTitle: null, + department: null, + Now).Value); + } + + await dbContext.SaveChangesAsync().ConfigureAwait(false); + dbContext.ChangeTracker.Clear(); + + WorkspaceTerminationFence fence = WorkspaceTerminationFence.Freeze( + Guid.Parse("af000000-0000-0000-0000-000000000001"), + TenantId, + Guid.Parse("af000000-0000-0000-0000-000000000002"), + Guid.Parse("af000000-0000-0000-0000-000000000003"), + approvalRevision: 1, + Guid.Parse("af000000-0000-0000-0000-000000000004"), + new string('a', 64), + "tenant-export-operator", + Now.AddMinutes(1)).Value; + dbContext.WorkspaceTerminationFences.Add(fence); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + dbContext.ChangeTracker.Clear(); + + PagedOutcomeReader outcomes = new(); + WorkspacesTenantTerminationExportContributor contributor = new( + dbContext, + new TestScopeContext(), + new TestClock(Now.AddMinutes(2)), + outcomes, + NullLogger.Instance); + CollectingSink sink = new(); + + TenantTerminationContributionResult result = + await contributor.ExportAsync( + new TenantTerminationExportRequest( + new TenantTerminationContributionRequest( + TenantTerminationContract.CurrentVersion, + TenantId, + fence.ProcessId, + fence.CaseId, + fence.ApprovalRevision, + OperationRevision: 2, + fence.TerminationEpoch, + TenantTerminationContributionPhase.Export, + Guid.Parse( + "af000000-0000-0000-0000-000000000005"), + Guid.Parse( + "af000000-0000-0000-0000-000000000006"), + fence.PolicyEvidenceSha256, + "tenant-exporter", + Now.AddMinutes(10)), + FreezeOperationRevision: 1, + fence.Version, + fence.PolicyEvidenceSha256, + fence.CreatedAtUtc), + sink, + CancellationToken.None).ConfigureAwait(false); + + Assert.Equal( + TenantTerminationContributionStatus.RetryRequired, + result.Status); + Assert.Equal( + "workspace.termination.export-identity-anchor-unavailable", + result.ResultCode); + Assert.Equal([500, 2], outcomes.BatchSizes); + Assert.Empty(sink.Records); + } + + private static async Task CreateSchemaAndSeedAsync( + string connectionString) + { + await using WorkspacesDbContext dbContext = + CreateDbContext(connectionString); + Assert.True(await dbContext.Database.EnsureCreatedAsync() + .ConfigureAwait(false)); + WorkspaceStaffOnboarding application = CreateApplication(); + dbContext.StaffOnboardingApplications.Add(application); + dbContext.StaffOnboardingProcessingRestrictionProjections.Add( + WorkspaceStaffOnboardingProcessingRestrictionProjection.Create( + TenantId, + ApplicationId, + WorkspaceStaffOnboardingProcessingRestrictionContract + .CurrentVersion, + Now).Value); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + + private static WorkspacesDbContext CreateDbContext( + string connectionString) => + new( + new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .Options, + new TestScopeContext()); + + private static WorkspaceStaffOnboarding CreateApplication() => + WorkspaceStaffOnboarding.Create( + ApplicationId, + TenantId, + WorkspaceStaffOnboardingSource.EnrollmentLink, + SourceId, + SubjectId, + "verified@example.test", + "Applicant Profile", + "Applicant Legal", + "work@example.test", + "+1 555 0100", + "EMP-AC", + "Manager", + "Operations", + Now).Value; + + private sealed class MutableOutcomeReader + : IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + { + public TaskCompletionSource ReadStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public StaffWorkspaceOnboardingIdentityAnchorOutcome? Outcome + { + get; + set; + } + + public Task> ReadAsync( + IReadOnlyList + requests, + CancellationToken cancellationToken = default) + { + StaffWorkspaceOnboardingIdentityAnchorOutcomeRequest request = + Assert.Single(requests); + this.ReadStarted.TrySetResult(); + return Task.FromResult>([ + this.Outcome ?? new( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Absent, + StaffMemberId: null, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Unknown, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Unknown, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null) + ]); + } + } + + private sealed class CollectingSink : IDataRightsExportSink + { + public List Records { get; } = []; + + public ValueTask WriteAsync( + DataRightsExportRecord record, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + this.Records.Add(record); + return ValueTask.CompletedTask; + } + } + + private sealed class PagedOutcomeReader + : IStaffWorkspaceOnboardingIdentityAnchorOutcomeReader + { + public List BatchSizes { get; } = []; + + public Task> ReadAsync( + IReadOnlyList + requests, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + this.BatchSizes.Add(requests.Count); + bool firstPage = this.BatchSizes.Count == 1; + return Task.FromResult>( + requests.Select(request => firstPage + ? new StaffWorkspaceOnboardingIdentityAnchorOutcome( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Absent, + StaffMemberId: null, + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Unknown, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Unknown, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null) + : new StaffWorkspaceOnboardingIdentityAnchorOutcome( + request.ApplicationId, + StaffWorkspaceOnboardingIdentityAnchorOutcomeStatus + .Unresolved, + Guid.Parse( + "af000000-0000-0000-0000-000000000007"), + StaffWorkspaceOnboardingIdentityAnchorTargetLifecycle + .Active, + StaffWorkspaceOnboardingIdentityAnchorSubjectMatch + .Exact, + WorkspaceApplicationVersion: null, + ResolutionDisposition: null, + Guid.Parse( + "af000000-0000-0000-0000-000000000008")) + ).ToArray()); + } + } + + private sealed class TestClock(DateTimeOffset utcNow) : ISystemClock + { + public DateTimeOffset UtcNow { get; } = utcNow; + } + + private sealed class TestScopeContext : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => TenantId; + } +} diff --git a/tests/Integration.Tests/Workspaces/WorkspacesDataRightsExportIntegrationTests.Destroy.cs b/tests/Integration.Tests/Workspaces/WorkspacesDataRightsExportIntegrationTests.Destroy.cs index 9b1a8a3b..5a3b3a55 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); } } @@ -317,7 +322,7 @@ private static async Task SeedDenseDestroyStateAsync( using IServiceScope scope = services.CreateScope(); WorkspacesDbContext context = scope.ServiceProvider .GetRequiredService(); - _ = SeedGraph(context, TenantA, SubjectId); + _ = await SeedGraphAsync(context, TenantA, SubjectId); WorkspaceStaffOnboarding onboarding = context.StaffOnboardingApplications.Local.Single(); @@ -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..d6e3e9a2 100644 --- a/tests/Integration.Tests/Workspaces/WorkspacesDataRightsExportIntegrationTests.cs +++ b/tests/Integration.Tests/Workspaces/WorkspacesDataRightsExportIntegrationTests.cs @@ -4,6 +4,8 @@ namespace Integration.Tests; using System.Data.Common; using System.Text.Json; using BunkFy.Modules.DataRights.Contracts; +using BunkFy.Modules.Staff.Application; +using BunkFy.Modules.Staff.Application.Ports; using BunkFy.Modules.Staff.Contracts; using BunkFy.Modules.Staff.Domain.Aggregates; using BunkFy.Modules.Staff.Persistence; @@ -11,6 +13,7 @@ namespace Integration.Tests; using BunkFy.Modules.Workspaces.Domain; using BunkFy.Modules.Workspaces.Domain.Termination; using BunkFy.Modules.Workspaces.Persistence; +using Gma.Framework.Cqrs.Infrastructure; using Gma.Framework.Runtime.Time; using Gma.Framework.Scoping; using Microsoft.EntityFrameworkCore; @@ -66,8 +69,10 @@ public async Task Workspaces_discovery_and_export_use_authoritative_postgresql_r await dbContext.Database.GetService() .MigrateAsync(PreviousMigration); await dbContext.Database.MigrateAsync(); - graph = SeedGraph(dbContext, TenantA, SubjectId); - await dbContext.SaveChangesAsync(); + graph = await SeedGraphAsync( + dbContext, + TenantA, + SubjectId); } using ServiceProvider tenantBProvider = @@ -96,6 +101,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(); } @@ -125,9 +139,54 @@ await dbContext.Database.GetService() "91000000-0000-0000-0000-000000000001"), Now).Value); await staffDbContext.SaveChangesAsync(); - WorkspaceTerminationFence fence = CreateTerminationFence(); - dbContext.WorkspaceTerminationFences.Add(fence); + + Guid resolutionEventId = Assert.IsType( + graph.Onboarding.IdentityAnchorResolutionEventId); + long workspaceApplicationVersion = Assert.IsType( + graph.Onboarding.IdentityAnchorResolutionApplicationVersion); + IStaffIdentityProvisioningAnchorRepository anchors = + scope.ServiceProvider.GetRequiredService< + IStaffIdentityProvisioningAnchorRepository>(); + await anchors.AddAsync( + new StaffIdentityProvisioningAnchorRecord( + TenantA, + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + graph.Onboarding.Id, + StaffMemberId, + Now.AddMinutes(2), + resolutionEventId), + CancellationToken.None); + await staffDbContext.SaveChangesAsync(); + + IStaffIdentityProvisioningAnchorResolutionRepository resolutions = + scope.ServiceProvider.GetRequiredService< + IStaffIdentityProvisioningAnchorResolutionRepository>(); + await resolutions.AddAsync( + new StaffIdentityProvisioningAnchorResolutionRecord( + TenantA, + StaffIdentityProvisioningSourceKind.WorkspaceOnboarding, + graph.Onboarding.Id, + StaffMemberId, + workspaceApplicationVersion, + StaffWorkspaceOnboardingIdentityAnchorResolutionDisposition + .CompletedRedacted, + resolutionEventId, + Now.AddMinutes(3)), + CancellationToken.None); + await staffDbContext.SaveChangesAsync(); + + WorkspaceStaffOnboarding onboardingForObservation = + await dbContext.StaffOnboardingApplications.SingleAsync( + application => application.Id == graph.Onboarding.Id); + Assert.True(onboardingForObservation.ObserveResolution( + resolutionEventId, + StaffMemberId, + workspaceApplicationVersion, + onboardingForObservation + .IdentityAnchorResolutionDisposition!.Value, + Now.AddMinutes(4)).IsSuccess); await dbContext.SaveChangesAsync(); + dbContext.ChangeTracker.Clear(); IDataRightsSubjectDiscoveryContributor[] discoveries = @@ -367,6 +426,12 @@ await AssertExportAsync( .Select(record => record.RecordId) .ToArray()); + dbContext.ChangeTracker.Clear(); + WorkspaceTerminationFence fence = CreateTerminationFence(); + dbContext.WorkspaceTerminationFences.Add(fence); + await dbContext.SaveChangesAsync(); + dbContext.ChangeTracker.Clear(); + await AssertTenantTerminationExportAsync( scope.ServiceProvider, tenantAProvider, @@ -396,12 +461,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 +478,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 +517,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, @@ -564,7 +657,7 @@ FROM pg_indexes } } - private static SeededGraph SeedGraph( + private static async Task SeedGraphAsync( WorkspacesDbContext dbContext, string tenantId, string subjectId) @@ -588,17 +681,6 @@ private static SeededGraph SeedGraph( "Manager", "Operations", Now).Value; - Assert.True( - onboarding.ObserveInvitationAccepted( - Now.AddMinutes(1)).IsSuccess); - Assert.True( - onboarding.MarkStaffReady( - StaffMemberId, - Now.AddMinutes(2)).IsSuccess); - Assert.True( - onboarding.Complete( - Now.AddMinutes(3)).IsSuccess); - WorkspaceStaffAccessProcess process = WorkspaceStaffAccessProcess.Create( Guid.Parse( @@ -654,12 +736,39 @@ 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); - return new SeededGraph(receipt); + + await dbContext.SaveChangesAsync(); + Assert.True( + onboarding.ObserveInvitationAccepted( + Now.AddMinutes(1)).IsSuccess); + await dbContext.SaveChangesAsync(); + Assert.True( + onboarding.MarkStaffReady( + StaffMemberId, + Guid.NewGuid(), + Guid.NewGuid(), + Now.AddMinutes(2)).IsSuccess); + await dbContext.SaveChangesAsync(); + Assert.True( + onboarding.Complete( + Now.AddMinutes(3)).IsSuccess); + await dbContext.SaveChangesAsync(); + return new SeededGraph(receipt, onboarding); } private static DataRightsSubjectDiscoveryRequest DiscoveryRequest( @@ -750,6 +859,8 @@ private static ServiceProvider CreatePersistenceProvider( new TestScopeContext(tenantId)); builder.Services.AddSingleton( clock ?? new TestClock(ExportNowUtc)); + builder.AddCqrsInfrastructure(); + builder.Services.AddStaffApplication(); builder.AddStaffPersistence(); builder.AddWorkspacesPersistence(); return builder.Services.BuildServiceProvider(); @@ -806,5 +917,6 @@ private sealed class TestClock(DateTimeOffset utcNow) : ISystemClock } private sealed record SeededGraph( - WorkspaceStaffRetentionCorrelationReceipt Receipt); + WorkspaceStaffRetentionCorrelationReceipt Receipt, + WorkspaceStaffOnboarding Onboarding); } 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")]