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
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 @@ -294,7 +294,7 @@
"Api": {
"ActorIdClaim": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
"TenantIdClaim": "scope_id",
"RequireTenantClaimMatch": true,
"RequireTenantClaimMatch": false,
"AllowGeneratedPasswordResponses": false
},
"Audit": {
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
Expand Up @@ -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<InvalidOperationException>(() =>
OperationsNotificationsStaffHistoryPolicyEvidence
.ComputeSnapshotSha256(reference, snapshot));
}

[Fact]
public async Task Policy_binds_open_history_to_exact_departed_staff_authority()
{
Expand Down
Loading