Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/architecture/workspaces-and-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 17 additions & 8 deletions docs/planning/staff-owner-identity-bootstrap-hardening-task.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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.
2 changes: 1 addition & 1 deletion src/BunkFy.Host.AdminApi/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion src/BunkFy.Host.Worker/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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.")
};
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -82,6 +83,11 @@ public static IServiceCollection AddBunkFyWorkspaces(
OrganizationMembershipAccessHandler>(
AccessControlModuleMetadata.Name,
OrganizationsModuleMetadata.Name);
services.AddIntegrationEventHandler<
WorkspaceStaffOnboardingIdentityAnchorResolvedIntegrationEvent,
WorkspaceStaffOnboardingIdentityAnchorResolutionHandler>(
StaffModuleMetadata.Name,
WorkspacesModuleMetadata.Name);

return services;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace BunkFy.Extensions.Workspaces;
internal sealed class OrganizationOwnerStaffBootstrapHandler(
IStaffIdentityBootstrapper staff,
IAuthMemberContactReader contacts,
IOrganizationAccessDecisionReader organizationAccess,
IOrganizationMembershipInspector memberships,
IWorkspaceOperationalAdmissionPolicy operationalAdmission,
IOptions<BunkFyWorkspacesOptions> options)
: IIntegrationEventHandler<OrganizationMembershipChangedIntegrationEvent>
Expand All @@ -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(
Expand All @@ -64,6 +101,7 @@ OrganizationAccessDecision.MembershipNotFound or
StaffIdentityBootstrapResult result = await staff.BootstrapAsync(
new StaffIdentityBootstrapRequest(
integrationEvent.EventId,
integrationEvent.MembershipId,
integrationEvent.SubjectId,
displayName,
verifiedEmail,
Expand Down
Original file line number Diff line number Diff line change
@@ -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.")
};
}
Loading
Loading