From ccb7d1fd6bf2f608a39a64f1c9ae2a5faa8780fe Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Wed, 23 Sep 2026 16:18:40 -0400 Subject: [PATCH 1/4] test(tenancy): prove tenant identity read isolation (#157) --- .../tenant-identity-read-leak-matrix.md | 23 + .../TenantIdentityReadLeakMatrixE2ETests.cs | 497 ++++++++++++++++++ 2 files changed, 520 insertions(+) create mode 100644 docs/architecture/tenant-identity-read-leak-matrix.md create mode 100644 test/Compliance.Tests/E2E/TenantIdentityReadLeakMatrixE2ETests.cs diff --git a/docs/architecture/tenant-identity-read-leak-matrix.md b/docs/architecture/tenant-identity-read-leak-matrix.md new file mode 100644 index 0000000..4c1fc01 --- /dev/null +++ b/docs/architecture/tenant-identity-read-leak-matrix.md @@ -0,0 +1,23 @@ +# Tenant identity read isolation evidence + +[EN-01 backend #157](https://github.com/bdgrz/compliance/issues/157) requires +standalone and split API/worker proof for each implemented tenant-owned read. +`TenantIdentityReadLeakMatrixE2ETests` runs the same two-tenant broker scenario in +both host modes. It creates unrelated verified owners, tenant records, and two +invitations per tenant, then uses separate owner, outsider, and platform-operator +sessions over HTTP and MCP. Personal invitation acceptance is HTTP-only and is +outside this read matrix. + +| Read family | Current HTTP and MCP proof | Remaining proof | +| --- | --- | --- | +| Tenant detail | Owners see their own tenant; another owner and an outsider receive `NotFound`. A platform operator can read both tenants as the accepted metadata exception. | Other tenant-owned details are inventoried in their own capability matrices. | +| My tenants | Each owner sees only their tenant. The outsider and operator see no memberships. Both transports traverse `limit=1` cursors, including empty filtered pages; unauthenticated HTTP is rejected. | This is a membership view, not a tenant inventory for the operator. | +| Tenant members | The operator sees only the owner of each tenant. A tenant owner and outsider cannot use the operator-only list. | This fixture has one member per tenant, so it does not prove member-list pagination after a second membership activates. | +| Tenant invitations | Each owner sees only their own two invitation emails while traversing `limit=1` cursors. Foreign tenant paths fail; filtering the owned tenant with the other tenant's email returns an empty page. | Delivery and personal acceptance have separate lifecycle evidence; this scenario does not assert either. | +| Effective member access | Each owner reads their own tenant access. Foreign tenant and foreign user combinations return `NotFound`. | Actor snapshots and later identity replacement/deprovisioning still need lifecycle proof. | + +These read contracts return items and continuation cursors, without a total +count field. The scenario waits for projections and invitation authority before +asserting isolation; it does not treat an empty page caused by lag as proof of +filtering. Broader current-surface coverage and actor lifecycle evidence remain +on [#157](https://github.com/bdgrz/compliance/issues/157). diff --git a/test/Compliance.Tests/E2E/TenantIdentityReadLeakMatrixE2ETests.cs b/test/Compliance.Tests/E2E/TenantIdentityReadLeakMatrixE2ETests.cs new file mode 100644 index 0000000..f256634 --- /dev/null +++ b/test/Compliance.Tests/E2E/TenantIdentityReadLeakMatrixE2ETests.cs @@ -0,0 +1,497 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Bdgrz.Compliance; +using Bdgrz.Compliance.Features.AccessControl; +using Bdgrz.Compliance.Features.Tenants; +using Bdgrz.Compliance.Features.UserIdentities; +using Cntryl.Portia; +using Cntryl.Portia.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Bdgrz.Compliance.Tests.E2E; + +[Collection(BrokerCollectionDefinition.Name)] +[Trait("Category", "BrokerIntegration")] +public sealed class TenantIdentityReadLeakMatrixE2ETests(BrokerStackFixture broker) + : IClassFixture +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ShouldScopeTenantMemberInvitationAndSelfReadsGivenTwoTenants(bool splitHosts) + { + // Arrange: two unrelated owners, tenants, and invitation populations share one broker. + var applicationName = $"compliance-identity-read-{Guid.NewGuid():N}"; + var ownerAEmail = $"owner-a-{Guid.NewGuid():N}@example.com"; + // Both theory cases share the broker's fixed platform-operator roster stream. + const string operatorEmail = "operator-identity-read-matrix@example.com"; + var ownerBEmail = $"owner-b-{Guid.NewGuid():N}@example.com"; + var outsiderEmail = $"outsider-{Guid.NewGuid():N}@example.com"; + var invitationsA = new[] + { + $"a-one-{Guid.NewGuid():N}@example.com", + $"a-two-{Guid.NewGuid():N}@example.com", + }; + var invitationsB = new[] + { + $"b-one-{Guid.NewGuid():N}@example.com", + $"b-two-{Guid.NewGuid():N}@example.com", + }; + IHost? worker = null; + if (splitHosts) + { + var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings + { + EnvironmentName = "Development", + }); + builder.Configuration["Fitz:Endpoint"] = broker.WebSocketEndpoint; + builder.Configuration["Fitz:ApplicationName"] = applicationName; + builder.Configuration["Fitz:StartupTimeoutSeconds"] = "30"; + builder.Services.AddCompliance(builder.Configuration, developerAuthentication: true).AddWorkers(); + worker = builder.Build(); + await worker.StartAsync(); + } + + try + { + Uuid ownerAId; + Uuid ownerBId; + Uuid operatorId; + Uuid tenantA; + Uuid tenantB; + await using (var seedFactory = E2EAppFactory.Create(broker, applicationName)) + { + using var ownerA = CreateClient(seedFactory, splitHosts); + using var ownerB = CreateClient(seedFactory, splitHosts); + using var bootstrapOperator = CreateClient(seedFactory, splitHosts); + ownerAId = Uuid.Parse(await TenantInvitationE2ETests.LoginAsync(ownerA, ownerAEmail), + CultureInfo.InvariantCulture); + ownerBId = Uuid.Parse(await TenantInvitationE2ETests.LoginAsync(ownerB, ownerBEmail), + CultureInfo.InvariantCulture); + operatorId = Uuid.Parse(await TenantInvitationE2ETests.LoginAsync( + bootstrapOperator, operatorEmail), CultureInfo.InvariantCulture); + await VerifyEmailAsync(seedFactory, worker, ownerA, ownerAId, ownerAEmail); + await VerifyEmailAsync(seedFactory, worker, ownerB, ownerBId, ownerBEmail); + tenantA = await RegisterTenantAsync(ownerA, "Identity Read A"); + tenantB = await RegisterTenantAsync(ownerB, "Identity Read B"); + await WaitForActiveAsync(ownerA, tenantA); + await WaitForActiveAsync(ownerB, tenantB); + await WaitForInvitationAuthorityAsync(ownerA, tenantA); + await WaitForInvitationAuthorityAsync(ownerB, tenantB); + foreach (var email in invitationsA) + await InviteAsync(ownerA, tenantA, email); + foreach (var email in invitationsB) + await InviteAsync(ownerB, tenantB, email); + await WaitForInvitationsAsync(ownerA, tenantA, invitationsA); + await WaitForInvitationsAsync(ownerB, tenantB, invitationsB); + } + + // Act + // The operator grant is platform metadata authority, not tenant membership. + await using var factory = E2EAppFactory.Create(broker, applicationName) + .WithWebHostBuilder(host => host.ConfigureTestServices(services => + services.AddSingleton(new PlatformOperatorAuthority([operatorId])))); + using var ownerAClient = CreateClient(factory, splitHosts); + using var ownerBClient = CreateClient(factory, splitHosts); + using var operatorClient = CreateClient(factory, splitHosts); + using var outsider = CreateClient(factory, splitHosts); + Assert.Equal(ownerAId, Uuid.Parse(await TenantInvitationE2ETests.LoginAsync( + ownerAClient, ownerAEmail), CultureInfo.InvariantCulture)); + Assert.Equal(ownerBId, Uuid.Parse(await TenantInvitationE2ETests.LoginAsync( + ownerBClient, ownerBEmail), CultureInfo.InvariantCulture)); + Assert.Equal(operatorId, Uuid.Parse(await TenantInvitationE2ETests.LoginAsync( + operatorClient, operatorEmail), CultureInfo.InvariantCulture)); + await TenantInvitationE2ETests.LoginAsync(outsider, outsiderEmail); + + // Assert + // Current tenant metadata permits an operator; all other reads retain their own rules. + AssertTenant(await ReadHttpAsync(ownerBClient, TenantPath(tenantB)), tenantB); + AssertTenant(await ReadHttpAsync(ownerAClient, TenantPath(tenantA)), tenantA); + AssertTenant(await ReadHttpAsync(operatorClient, TenantPath(tenantA)), tenantA); + AssertTenant(await ReadHttpAsync(operatorClient, TenantPath(tenantB)), tenantB); + await AssertDeniedHttpAsync(ownerBClient, TenantPath(tenantA), HttpStatusCode.NotFound, + "Identity Read A"); + await AssertDeniedHttpAsync(outsider, TenantPath(tenantB), HttpStatusCode.NotFound, + "Identity Read B"); + + Assert.Equal([tenantA.ToString()], await ReadMyTenantIdsAsync(ownerAClient)); + Assert.Equal([tenantB.ToString()], await ReadMyTenantIdsAsync(ownerBClient)); + Assert.Empty(await ReadMyTenantIdsAsync(outsider)); + Assert.Empty(await ReadMyTenantIdsAsync(operatorClient)); + using (var anonymous = factory.CreateClient()) + await AssertDeniedHttpAsync(anonymous, "/api/v1/tenants/mine", + HttpStatusCode.Unauthorized, "Identity Read A"); + + var membersA = await ReadIdsAsync(operatorClient, TenantPath(tenantA) + "/members", + "user_id"); + var membersB = await ReadIdsAsync(operatorClient, TenantPath(tenantB) + "/members", + "user_id"); + Assert.Equal([ownerAId.ToString()], membersA); + Assert.Equal([ownerBId.ToString()], membersB); + await AssertDeniedHttpAsync(ownerBClient, TenantPath(tenantB) + "/members", + HttpStatusCode.Forbidden, ownerAEmail); + await AssertDeniedHttpAsync(outsider, TenantPath(tenantA) + "/members", + HttpStatusCode.Forbidden, ownerAEmail); + + Assert.Equal(invitationsA.Order(StringComparer.Ordinal), + (await ReadInvitationEmailsAsync(ownerAClient, tenantA)).Order(StringComparer.Ordinal)); + Assert.Equal(invitationsB.Order(StringComparer.Ordinal), + (await ReadInvitationEmailsAsync(ownerBClient, tenantB)).Order(StringComparer.Ordinal)); + await AssertDeniedHttpAsync(ownerBClient, TenantPath(tenantA) + "/member-invitations", + HttpStatusCode.NotFound, invitationsA[0]); + await AssertDeniedHttpAsync(ownerAClient, TenantPath(tenantB) + "/member-invitations", + HttpStatusCode.NotFound, invitationsB[0]); + var foreignFilter = await ReadHttpAsync(ownerBClient, + TenantPath(tenantB) + "/member-invitations?email_address=" + + Uri.EscapeDataString(invitationsA[0])); + Assert.Empty(foreignFilter.GetProperty("items").EnumerateArray()); + + var accessA = await ReadHttpAsync(ownerAClient, + TenantPath(tenantA) + $"/members/{ownerAId}/access"); + var accessB = await ReadHttpAsync(ownerBClient, + TenantPath(tenantB) + $"/members/{ownerBId}/access"); + AssertMemberAccess(accessA, tenantA, ownerAId); + AssertMemberAccess(accessB, tenantB, ownerBId); + await AssertDeniedHttpAsync(ownerBClient, + TenantPath(tenantA) + $"/members/{ownerAId}/access", + HttpStatusCode.NotFound, ownerAEmail); + await AssertDeniedHttpAsync(ownerBClient, + TenantPath(tenantB) + $"/members/{ownerAId}/access", + HttpStatusCode.NotFound, ownerAEmail); + await AssertDeniedHttpAsync(ownerAClient, + TenantPath(tenantB) + $"/members/{ownerBId}/access", + HttpStatusCode.NotFound, ownerBEmail); + + await using var ownerAMcp = await McpScenario.ConnectAsync(ownerAClient, + new Uri(ownerAClient.BaseAddress!, "/mcp")); + await using var operatorMcp = await McpScenario.ConnectAsync(operatorClient, + new Uri(operatorClient.BaseAddress!, "/mcp")); + await using var ownerBMcp = await McpScenario.ConnectAsync(ownerBClient, + new Uri(ownerBClient.BaseAddress!, "/mcp")); + await using var outsiderMcp = await McpScenario.ConnectAsync(outsider, + new Uri(outsider.BaseAddress!, "/mcp")); + AssertTenant(await ReadToolAsync(ownerBMcp, "bdgrz.tenant.get", + TenantInput(tenantB)), tenantB); + AssertTenant(await ReadToolAsync(ownerAMcp, "bdgrz.tenant.get", + TenantInput(tenantA)), tenantA); + AssertTenant(await ReadToolAsync(operatorMcp, "bdgrz.tenant.get", + TenantInput(tenantB)), tenantB); + AssertTenant(await ReadToolAsync(operatorMcp, "bdgrz.tenant.get", + TenantInput(tenantA)), tenantA); + _ = await ownerBMcp.When("bdgrz.tenant.get", TenantInput(tenantA)) + .ExpectFailure("NotFound"); + _ = await outsiderMcp.When("bdgrz.tenant.get", TenantInput(tenantB)) + .ExpectFailure("NotFound"); + + Assert.Equal([tenantA.ToString()], await ReadMcpMyTenantIdsAsync(ownerAMcp)); + Assert.Equal([tenantB.ToString()], await ReadMcpMyTenantIdsAsync(ownerBMcp)); + Assert.Empty(await ReadMcpMyTenantIdsAsync(outsiderMcp)); + Assert.Empty(await ReadMcpMyTenantIdsAsync(operatorMcp)); + Assert.Equal(membersA, Ids(await ReadToolAsync(operatorMcp, + "bdgrz.tenant-member.list", TenantInput(tenantA)), "user_id")); + Assert.Equal(membersB, Ids(await ReadToolAsync(operatorMcp, + "bdgrz.tenant-member.list", TenantInput(tenantB)), "user_id")); + _ = await ownerBMcp.When("bdgrz.tenant-member.list", TenantInput(tenantB)) + .ExpectFailure("Forbidden"); + _ = await outsiderMcp.When("bdgrz.tenant-member.list", TenantInput(tenantA)) + .ExpectFailure("Forbidden"); + + Assert.Equal(invitationsA.Order(StringComparer.Ordinal), + (await ReadMcpInvitationEmailsAsync(ownerAMcp, tenantA)) + .Order(StringComparer.Ordinal)); + Assert.Equal(invitationsB.Order(StringComparer.Ordinal), + (await ReadMcpInvitationEmailsAsync(ownerBMcp, tenantB)) + .Order(StringComparer.Ordinal)); + _ = await ownerBMcp.When("bdgrz.tenant-invitation.list", TenantInput(tenantA)) + .ExpectFailure("NotFound"); + _ = await ownerAMcp.When("bdgrz.tenant-invitation.list", TenantInput(tenantB)) + .ExpectFailure("NotFound"); + var foreignMcpFilter = TenantInput(tenantB); + foreignMcpFilter["email_address"] = invitationsA[0]; + Assert.Empty(Ids(await ReadToolAsync(ownerBMcp, "bdgrz.tenant-invitation.list", + foreignMcpFilter), "email_address")); + + AssertMemberAccess(await ReadToolAsync(ownerAMcp, "bdgrz.member.access.get", + MemberInput(tenantA, ownerAId)), tenantA, ownerAId); + AssertMemberAccess(await ReadToolAsync(ownerBMcp, "bdgrz.member.access.get", + MemberInput(tenantB, ownerBId)), tenantB, ownerBId); + _ = await ownerBMcp.When("bdgrz.member.access.get", + MemberInput(tenantA, ownerAId)).ExpectFailure("NotFound"); + _ = await ownerBMcp.When("bdgrz.member.access.get", + MemberInput(tenantB, ownerAId)).ExpectFailure("NotFound"); + _ = await ownerAMcp.When("bdgrz.member.access.get", + MemberInput(tenantB, ownerBId)).ExpectFailure("NotFound"); + _ = await outsiderMcp.When("bdgrz.member.access.get", + MemberInput(tenantA, ownerAId)).ExpectFailure("NotFound"); + } + finally + { + if (worker is not null) + { + await worker.StopAsync(); + worker.Dispose(); + } + } + } + + static async Task VerifyEmailAsync(Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory factory, + IHost? worker, HttpClient client, Uuid userId, string email) + { + var path = $"/api/v1/users/{userId}/email-addresses/{email}"; + await WaitUntilAsync(async () => + { + using var response = await client.GetAsync(path); + return response.StatusCode == HttpStatusCode.OK; + }, "The email reservation was not projected."); + if ((await ReadHttpAsync(client, path)).GetProperty("verified").GetBoolean()) + return; + using var issued = await client.PostAsync(path + "/challenges", null); + Assert.Equal(HttpStatusCode.NoContent, issued.StatusCode); + var delivery = (worker?.Services ?? factory.Services) + .GetRequiredService(); + string? token = null; + await WaitUntilAsync(() => + { + var found = delivery.TryGetLatest(userId, email, out token); + return Task.FromResult(found); + }, "The challenge token was not delivered."); + await WaitUntilAsync(async () => + { + using var response = await client.GetAsync(path + "/challenges/status"); + if (response.StatusCode != HttpStatusCode.OK) + return false; + var status = await ReadJsonAsync(response); + return status.GetProperty("delivery_status").GetString() == "delivered"; + }, "The challenge delivery outcome was not recorded."); + using var verified = await client.PostAsJsonAsync(path + "/verifications", new { token }); + Assert.Equal(HttpStatusCode.NoContent, verified.StatusCode); + await WaitUntilAsync(async () => + { + using var response = await client.GetAsync(path); + if (response.StatusCode != HttpStatusCode.OK) + return false; + return (await ReadJsonAsync(response)).GetProperty("verified").GetBoolean(); + }, "The verified address was not projected."); + } + + static async Task RegisterTenantAsync(HttpClient client, string name) + { + using var response = await client.PostAsJsonAsync("/api/v1/tenants", new + { + name, + slug = $"identity-read-{Guid.NewGuid():N}"[..24], + }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return Uuid.Parse((await ReadJsonAsync(response)).GetProperty("tenant_id").GetString()!, + CultureInfo.InvariantCulture); + } + + static Task WaitForActiveAsync(HttpClient client, Uuid tenantId) => + WaitUntilAsync(async () => + { + using var response = await client.GetAsync(TenantPath(tenantId)); + return response.StatusCode == HttpStatusCode.OK && + (await ReadJsonAsync(response)).GetProperty("status").GetString() == "active"; + }, $"Tenant {tenantId} did not activate."); + + static Task WaitForInvitationAuthorityAsync(HttpClient client, Uuid tenantId) => + WaitUntilAsync(async () => + { + using var response = await client.GetAsync( + TenantPath(tenantId) + "/member-invitations"); + return response.StatusCode == HttpStatusCode.OK; + }, $"Invitation authority for {tenantId} did not become available."); + + static async Task InviteAsync(HttpClient client, Uuid tenantId, string email) + { + using var response = await client.PostAsJsonAsync( + TenantPath(tenantId) + "/member-invitations", new + { + email_address = email, + built_in_role = BuiltInRbac.ComplianceParticipationRole, + }); + Assert.True(response.StatusCode == HttpStatusCode.NoContent, + $"Invitation returned {response.StatusCode}: " + + await response.Content.ReadAsStringAsync()); + } + + static Task WaitForInvitationsAsync(HttpClient client, Uuid tenantId, + IReadOnlyList expected) => + WaitUntilAsync(async () => + { + var page = await ReadHttpAsync(client, TenantPath(tenantId) + "/member-invitations"); + return expected.All(email => Ids(page, "email_address").Contains(email)); + }, $"Invitations for {tenantId} were not projected."); + + static async Task> ReadInvitationEmailsAsync(HttpClient client, Uuid tenantId) + { + var emails = new List(); + string? cursor = null; + var pages = 0; + do + { + var path = TenantPath(tenantId) + "/member-invitations?limit=1"; + if (cursor is not null) + path += "&cursor=" + Uri.EscapeDataString(cursor); + var page = await ReadHttpAsync(client, path); + Assert.True(Ids(page, "email_address").Length <= 1); + emails.AddRange(Ids(page, "email_address")); + cursor = page.GetProperty("next_cursor").GetString(); + Assert.True(++pages <= 10, "The invitation cursor did not terminate."); + } while (cursor is not null); + Assert.Equal(2, emails.Count); + return emails; + } + + static async Task> ReadMyTenantIdsAsync(HttpClient client) + { + var tenantIds = new List(); + string? cursor = null; + var pages = 0; + do + { + var path = "/api/v1/tenants/mine?limit=1"; + if (cursor is not null) + path += "&cursor=" + Uri.EscapeDataString(cursor); + var page = await ReadHttpAsync(client, path); + Assert.True(Ids(page, "tenant_id").Length <= 1); + tenantIds.AddRange(Ids(page, "tenant_id")); + cursor = page.GetProperty("next_cursor").GetString(); + Assert.True(++pages <= 10, "The self-list cursor did not terminate."); + } while (cursor is not null); + return tenantIds; + } + + static async Task> ReadMcpMyTenantIdsAsync(McpScenario mcp) + { + var tenantIds = new List(); + string? cursor = null; + var pages = 0; + do + { + var input = new Dictionary { ["limit"] = 1 }; + if (cursor is not null) + input["cursor"] = cursor; + var page = await ReadToolAsync(mcp, "bdgrz.tenant-membership.list-mine", input); + Assert.True(Ids(page, "tenant_id").Length <= 1); + tenantIds.AddRange(Ids(page, "tenant_id")); + cursor = page.GetProperty("next_cursor").GetString(); + Assert.True(++pages <= 10, "The MCP self-list cursor did not terminate."); + } while (cursor is not null); + return tenantIds; + } + + static async Task> ReadMcpInvitationEmailsAsync(McpScenario mcp, + Uuid tenantId) + { + var emails = new List(); + string? cursor = null; + var pages = 0; + do + { + var input = TenantInput(tenantId); + input["limit"] = 1; + if (cursor is not null) + input["cursor"] = cursor; + var page = await ReadToolAsync(mcp, "bdgrz.tenant-invitation.list", input); + Assert.True(Ids(page, "email_address").Length <= 1); + emails.AddRange(Ids(page, "email_address")); + cursor = page.GetProperty("next_cursor").GetString(); + Assert.True(++pages <= 10, "The MCP invitation cursor did not terminate."); + } while (cursor is not null); + Assert.Equal(2, emails.Count); + return emails; + } + + static async Task ReadIdsAsync(HttpClient client, string path, string field) => + Ids(await ReadHttpAsync(client, path), field); + + static async Task ReadHttpAsync(HttpClient client, string path) + { + using var response = await client.GetAsync(path); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return await ReadJsonAsync(response); + } + + static async Task AssertDeniedHttpAsync(HttpClient client, string path, + HttpStatusCode expected, string privateValue) + { + using var response = await client.GetAsync(path); + Assert.Equal(expected, response.StatusCode); + Assert.DoesNotContain(privateValue, await response.Content.ReadAsStringAsync(), + StringComparison.OrdinalIgnoreCase); + } + + static async Task ReadToolAsync(McpScenario mcp, string tool, + Dictionary input) + { + var call = await mcp.When(tool, input).ExpectSuccess(); + return Assert.IsType(call.StructuredJson).GetProperty("result"); + } + + static Dictionary TenantInput(Uuid tenantId) => + new() { ["tenant_id"] = tenantId.ToString() }; + + static Dictionary MemberInput(Uuid tenantId, Uuid userId) => + new() + { + ["tenant_id"] = tenantId.ToString(), + ["user_id"] = userId.ToString(), + }; + + static void AssertTenant(JsonElement tenant, Uuid tenantId) => + Assert.Equal(tenantId.ToString(), tenant.GetProperty("tenant_id").GetString()); + + static void AssertMemberAccess(JsonElement access, Uuid tenantId, Uuid userId) + { + Assert.Equal(tenantId.ToString(), access.GetProperty("tenant_id").GetString()); + Assert.Equal(userId.ToString(), access.GetProperty("user_id").GetString()); + Assert.Contains(access.GetProperty("effective_permissions").EnumerateArray(), + permission => permission.GetString() == RbacPermissions.TenantAccess); + } + + static string[] Ids(JsonElement page, string field) => + page.GetProperty("items").EnumerateArray() + .Select(item => item.GetProperty(field).GetString()!).ToArray(); + + static async Task ReadJsonAsync(HttpResponseMessage response) + { + using var document = await JsonDocument.ParseAsync( + await response.Content.ReadAsStreamAsync()); + return document.RootElement.Clone(); + } + + static async Task WaitUntilAsync(Func> predicate, string failure) + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(45); + while (DateTimeOffset.UtcNow < deadline) + { + if (await predicate()) + return; + await Task.Delay(250); + } + Assert.Fail(failure); + } + + static HttpClient CreateClient(Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory factory, + bool splitHosts) + { + var previousMode = Environment.GetEnvironmentVariable("COMPLIANCE_HOST_MODE"); + try + { + if (splitHosts) + Environment.SetEnvironmentVariable("COMPLIANCE_HOST_MODE", "api"); + return factory.CreateClient(); + } + finally + { + Environment.SetEnvironmentVariable("COMPLIANCE_HOST_MODE", previousMode); + } + } + + static string TenantPath(Uuid tenantId) => "/api/v1/tenants/" + tenantId; +} From 64aa53ca40d80e6ed95433c72a171417ac77ffe5 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Wed, 23 Sep 2026 16:26:24 -0400 Subject: [PATCH 2/4] docs(backlog): record completed email delivery proof (#360) --- docs/product/backend-delivery-ledger.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/product/backend-delivery-ledger.md b/docs/product/backend-delivery-ledger.md index 416573b..41e8be0 100644 --- a/docs/product/backend-delivery-ledger.md +++ b/docs/product/backend-delivery-ledger.md @@ -26,16 +26,16 @@ operations. ## Current backend queue -The live backlog has 79 open backend records: 31 in R1, 21 in R2, 6 in T1, -6 in T2, 7 in T3, 7 in F1, and 1 in AUTH. Two backend children are closed: -[R1-15a #238](https://github.com/bdgrz/compliance/issues/238) and [EN-01a -#324](https://github.com/bdgrz/compliance/issues/324). [AUTH-02a -#360](https://github.com/bdgrz/compliance/issues/360) is reopened. These counts describe +The live backlog has 78 open backend records: 31 in R1, 21 in R2, 6 in T1, +6 in T2, 7 in T3, and 7 in F1. Three backend children are closed: +[R1-15a #238](https://github.com/bdgrz/compliance/issues/238), [EN-01a +#324](https://github.com/bdgrz/compliance/issues/324), and [AUTH-02a +#360](https://github.com/bdgrz/compliance/issues/360). These counts describe tracking, not delivered coverage; partial implementations remain open. | Order | Reviewable capability | Backend records and exit gate | | --- | --- | --- | -| 1 | Identity and tenant foundation | Merged [PR #363](https://github.com/bdgrz/compliance/pull/363) supplies SMTP email verification; [AUTH-02a #360](https://github.com/bdgrz/compliance/issues/360) is reopened for the failed-delivery checkpoint stall addressed in this change. [R1-15 #152](https://github.com/bdgrz/compliance/issues/152), [EN-01 #157](https://github.com/bdgrz/compliance/issues/157), and [R1-01 #158](https://github.com/bdgrz/compliance/issues/158) then complete verified self-service, in-product operator authority, firm-staff denial, tenant authorization, and program scope. [R1-04a–e #183](https://github.com/bdgrz/compliance/issues/183) follow for member activation, scoped grants, teams, deprovisioning, and separation of duties. | +| 1 | Identity and tenant foundation | Merged [PR #363](https://github.com/bdgrz/compliance/pull/363) and [PR #367](https://github.com/bdgrz/compliance/pull/367) complete SMTP email verification and failed-delivery checkpoint recovery under [AUTH-02a #360](https://github.com/bdgrz/compliance/issues/360). [R1-15 #152](https://github.com/bdgrz/compliance/issues/152), [EN-01 #157](https://github.com/bdgrz/compliance/issues/157), and [R1-01 #158](https://github.com/bdgrz/compliance/issues/158) then complete verified self-service, in-product operator authority, firm-staff denial, tenant authorization, and program scope. [R1-04a–e #183](https://github.com/bdgrz/compliance/issues/183) follow for member activation, scoped grants, teams, deprovisioning, and separation of duties. | | 2 | Governed R1 source records | Complete version/history and boundary work [#160](https://github.com/bdgrz/compliance/issues/160), [#161](https://github.com/bdgrz/compliance/issues/161), [#162](https://github.com/bdgrz/compliance/issues/162), and [#246](https://github.com/bdgrz/compliance/issues/246), then criteria [#209](https://github.com/bdgrz/compliance/issues/209), application/system instances [#211](https://github.com/bdgrz/compliance/issues/211), manual workforce [#219](https://github.com/bdgrz/compliance/issues/219), technology [#227](https://github.com/bdgrz/compliance/issues/227), commitments [#229](https://github.com/bdgrz/compliance/issues/229), and providers [#231](https://github.com/bdgrz/compliance/issues/231). Use separate `SystemInstance` aggregate ownership from #211. | | 3 | R1 assurance decisions | Finish application scope/change [#215](https://github.com/bdgrz/compliance/issues/215)/[#217](https://github.com/bdgrz/compliance/issues/217), workforce changes/NHI/snapshot [#221](https://github.com/bdgrz/compliance/issues/221)/[#223](https://github.com/bdgrz/compliance/issues/223)/[#225](https://github.com/bdgrz/compliance/issues/225), snapshot enabler [#194](https://github.com/bdgrz/compliance/issues/194), controls [#197](https://github.com/bdgrz/compliance/issues/197)/[#198](https://github.com/bdgrz/compliance/issues/198), risks [#199](https://github.com/bdgrz/compliance/issues/199), and the owned readiness gap plan [#200](https://github.com/bdgrz/compliance/issues/200). Governed artifact storage [#196](https://github.com/bdgrz/compliance/issues/196) needs the external [Portia S3 adapter #69](https://github.com/cntryl/portia/issues/69) before evidence consumers close. | | 4 | R2 operation and review | Deliver the 21 R2 backend children as dependent control/policy/evidence, access-review, work, finding, advisor, and Type I entry capabilities. Start the access population at manually attested [#273](https://github.com/bdgrz/compliance/issues/273); use [F1-07 engagement assignments #269](https://github.com/bdgrz/compliance/issues/269) before advisor access [#275](https://github.com/bdgrz/compliance/issues/275). | @@ -70,7 +70,7 @@ decision records are authoritative. | [M0-D23 #80](https://github.com/bdgrz/compliance/issues/80) | [PR #166](https://github.com/bdgrz/compliance/pull/166), merge `97c77fe850745ca37e1c7176d678ae42a7fb137b` | [Assurance vocabulary decision](decisions/m0-d23-assurance-vocabulary.md), ten dependent issue updates, and exact-head CI are recorded in the issue. The later M0-D03 role decision is recorded separately. | | [AUTH-01 #142](https://github.com/bdgrz/compliance/issues/142) | [PR #144](https://github.com/bdgrz/compliance/pull/144), merge `2b3bb8b375c741455160ddbad70aa0c240564c56`; session reflection in [PR #150](https://github.com/bdgrz/compliance/pull/150) | Development and OIDC identity registration, provider identity resolution, signed HTTP session cookies, and logout are backend-only. The issue records focused acceptance coverage; no browser UI is implied by this row. | | [AUTH-02 #143](https://github.com/bdgrz/compliance/issues/143) | [PR #149](https://github.com/bdgrz/compliance/pull/149), merge `e46157ff647db6ef6f092cd3b954b83cc5f022ab` | Normalized email ownership, idempotent reservation, challenge hashing, verification transitions, owner-scoped reads, and HTTP routes are covered. That PR used `MockEmailChallengeDelivery`; the later production adapter is recorded under #360. | -| [AUTH-02a #360](https://github.com/bdgrz/compliance/issues/360), reopened | [PR #363](https://github.com/bdgrz/compliance/pull/363), merge `d3087cbe7d06fe8d7686b3a50563825ea17afe4c` | SMTP verification delivery is merged, but a recorded failed attempt then throws and stalls the global reactor checkpoint. This change makes failure terminal for that attempt, with explicit reissue and later-user progress; #360 needs final-head and post-merge proof before reclosure. A live SMTP relay and production deployment remain untested. | +| [AUTH-02a #360](https://github.com/bdgrz/compliance/issues/360) | [PR #363](https://github.com/bdgrz/compliance/pull/363), merge `d3087cbe7d06fe8d7686b3a50563825ea17afe4c`; [PR #367](https://github.com/bdgrz/compliance/pull/367), merge `da30af86e27d4cde238c40249b4ef978d9d68e06` | SMTP verification delivery is recoverable. A recorded SMTP or key failure advances the global reactor checkpoint, so later users proceed; explicit reissue starts a new attempt, while an unacknowledged crash retries the same token and effect identity. [Exact-head CI](https://github.com/bdgrz/compliance/actions/runs/35912692512) and [post-merge main CI](https://github.com/bdgrz/compliance/actions/runs/35914154576) passed. A live SMTP relay and production deployment remain untested. | | [EN-01a #324](https://github.com/bdgrz/compliance/issues/324) | [PR #325](https://github.com/bdgrz/compliance/pull/325), merge `da2ffde6cb950b19a97877367982576480a3bfb4` | Shared Portia composition fails closed with `RequireAuthorization` validation before Fitz startup; full handler registration, developer/OIDC/system-actor paths, Native AOT builds, and exact-head plus post-merge main CI are recorded in the PR and closure comment. [EN-01 #157](https://github.com/bdgrz/compliance/issues/157) remains open for first-consumer authorization and actor-attribution proof; its product decisions are accepted. | | [R1-15a #238](https://github.com/bdgrz/compliance/issues/238) | [PR #256](https://github.com/bdgrz/compliance/pull/256), merge `01590b1fc1f86657f3c6096bdcf382232720a577` | Operator-only tenant inventory uses Fitz 1.4.1 primary paging without request-path writes. Portia authorization, hyphenated HTTP path segments with snake_case interpolated/query/JSON values, and read-only MCP contracts, cross-tenant non-disclosure, standalone/split broker proof, focused tests, exact-head CI, and post-merge main CI are recorded in the issue closure comment. Product parent #237 and frontend child #239 remain open. | @@ -81,8 +81,8 @@ child issue's complete acceptance evidence. | Capability bundle | Merged pull requests | Open child records and remaining gate | | --- | --- | --- | -| R1-15 tenancy, membership, and split-host proof | [#151](https://github.com/bdgrz/compliance/pull/151), [#153](https://github.com/bdgrz/compliance/pull/153), [#154](https://github.com/bdgrz/compliance/pull/154), [#155](https://github.com/bdgrz/compliance/pull/155), [#164](https://github.com/bdgrz/compliance/pull/164), [#325](https://github.com/bdgrz/compliance/pull/325), [#361](https://github.com/bdgrz/compliance/pull/361), [#363](https://github.com/bdgrz/compliance/pull/363), [#364](https://github.com/bdgrz/compliance/pull/364) | [R1-15 backend #152](https://github.com/bdgrz/compliance/issues/152), [EN-01 #157](https://github.com/bdgrz/compliance/issues/157), and [R1-01 #158](https://github.com/bdgrz/compliance/issues/158) remain open. #361 adds verified self-service and firm-staff denial; #363 adds recoverable email challenge delivery; #364 adds the operator roster and an activation fence. #152 still depends on EN-01 acceptance; #157 needs broader actor, authorization, leak, and host-mode proof; #158 needs its Program-specific exit evidence. | -| Membership invitation lifecycle | [#185](https://github.com/bdgrz/compliance/pull/185), [#208](https://github.com/bdgrz/compliance/pull/208), [#263](https://github.com/bdgrz/compliance/pull/263), merge `bd63bad10a4cd6a3dff155ef2d3eb28d57f130c8` | [R1-04a backend #183](https://github.com/bdgrz/compliance/issues/183) remains open for identity replacement/recovery policy and proof, plus child-specific completion evidence. Accepted M0-D03 requires application-managed invitation email, not a provider-side invitation API; M0-A07 assigns lost-identity replacement, revocation, and recovery to #183. This change supplies a recoverable production SMTP adapter with at-least-once delivery; personal invitation acceptance remains HTTP-only. | +| R1-15 tenancy, membership, and split-host proof | [#151](https://github.com/bdgrz/compliance/pull/151), [#153](https://github.com/bdgrz/compliance/pull/153), [#154](https://github.com/bdgrz/compliance/pull/154), [#155](https://github.com/bdgrz/compliance/pull/155), [#164](https://github.com/bdgrz/compliance/pull/164), [#325](https://github.com/bdgrz/compliance/pull/325), [#361](https://github.com/bdgrz/compliance/pull/361), [#363](https://github.com/bdgrz/compliance/pull/363), [#364](https://github.com/bdgrz/compliance/pull/364), [#367](https://github.com/bdgrz/compliance/pull/367) | [R1-15 backend #152](https://github.com/bdgrz/compliance/issues/152), [EN-01 #157](https://github.com/bdgrz/compliance/issues/157), and [R1-01 #158](https://github.com/bdgrz/compliance/issues/158) remain open. #361 adds verified self-service and firm-staff denial; #363 adds email challenge delivery; #364 adds the operator roster and an activation fence; #367 repairs failed-delivery checkpoint liveness. #152 still depends on EN-01 acceptance; #157 needs broader actor, authorization, leak, and host-mode proof; #158 needs its Program-specific exit evidence. | +| Membership invitation lifecycle | [#185](https://github.com/bdgrz/compliance/pull/185), [#208](https://github.com/bdgrz/compliance/pull/208), [#263](https://github.com/bdgrz/compliance/pull/263), merge `bd63bad10a4cd6a3dff155ef2d3eb28d57f130c8`; [#367](https://github.com/bdgrz/compliance/pull/367), merge `da30af86e27d4cde238c40249b4ef978d9d68e06` | [R1-04a backend #183](https://github.com/bdgrz/compliance/issues/183) remains open for identity replacement/recovery policy and proof, plus child-specific completion evidence. Accepted M0-D03 requires application-managed invitation email, not a provider-side invitation API; M0-A07 assigns lost-identity replacement, revocation, and recovery to #183. #367 supplies a recoverable production SMTP adapter with at-least-once delivery; a recorded failure requires explicit reissue, while an unacknowledged crash retries the same token and effect identity. A live SMTP relay and production deployment remain untested. Personal invitation acceptance remains HTTP-only. | | Boundary versioning, immutable history, projection consistency, snapshots, and recovery replay | [#174](https://github.com/bdgrz/compliance/pull/174), [#175](https://github.com/bdgrz/compliance/pull/175), [#240](https://github.com/bdgrz/compliance/pull/240), [#242](https://github.com/bdgrz/compliance/pull/242), [#243](https://github.com/bdgrz/compliance/pull/243), [#244](https://github.com/bdgrz/compliance/pull/244), [#245](https://github.com/bdgrz/compliance/pull/245), [#247](https://github.com/bdgrz/compliance/pull/247), [#248](https://github.com/bdgrz/compliance/pull/248), [#318](https://github.com/bdgrz/compliance/pull/318), [#320](https://github.com/bdgrz/compliance/pull/320), [#321](https://github.com/bdgrz/compliance/pull/321), [#328](https://github.com/bdgrz/compliance/pull/328) | [M0-A01 #81](https://github.com/bdgrz/compliance/issues/81) accepts the application persistence and recovery boundary. The accepted [M0-A05 #85](https://github.com/bdgrz/compliance/issues/85) records projection-derived read semantics; product-owned calculations and cross-client rules remain in consuming backend work. [EN-02 #160](https://github.com/bdgrz/compliance/issues/160) remains open for complete reuse across owning contexts plus complete impact and deletion evidence. PR #321 proves retained-source Program recovery and application-projector replay under test. PR #328 extends that evidence with a controlled Docker tar restore into a distinct fresh local volume after source-volume loss, including archive checksum/manifest and pinned-image proof. Neither PR proves production backup/restore controls, measured achievement of the 15-minute RPO or 4-hour RTO, a global calculation snapshot, or cross-client authorization; whole-platform recovery delivery and its timed DevOps proof are tracked in [cntryl/portia#70](https://github.com/cntryl/portia/issues/70). | | Application inventory, source revisions, and change impact | [#249](https://github.com/bdgrz/compliance/pull/249), [#250](https://github.com/bdgrz/compliance/pull/250), [#252](https://github.com/bdgrz/compliance/pull/252), [#258](https://github.com/bdgrz/compliance/pull/258), [#259](https://github.com/bdgrz/compliance/pull/259), [#261](https://github.com/bdgrz/compliance/pull/261) | [R1-10a #211](https://github.com/bdgrz/compliance/issues/211), [EN-05 #195](https://github.com/bdgrz/compliance/issues/195), [R1-10b #213](https://github.com/bdgrz/compliance/issues/213), and [R1-10d #217](https://github.com/bdgrz/compliance/issues/217) retain verified ownership, classification authority, source identifiers, restricted discovery, import provenance, authorization, failure recovery, and complete projection proof. #261 carries the tenant-declared classification through Portia, HTTP/MCP, Fitz current/history views, and previews while keeping it `classification_unverified`; #258 adds pre-acceptance HTTP cancellation; #259 upgrades Portia 0.5.3 and exposes the bounded stage MCP command. | | Control, commitment, and risk drafts | [#251](https://github.com/bdgrz/compliance/pull/251), [#253](https://github.com/bdgrz/compliance/pull/253), [#254](https://github.com/bdgrz/compliance/pull/254) | [R1-05 #197](https://github.com/bdgrz/compliance/issues/197), [R1-13 #229](https://github.com/bdgrz/compliance/issues/229), and [R1-07 #199](https://github.com/bdgrz/compliance/issues/199) retain owner, applicability, review, activation, cross-record consistency, and risk acceptance gaps called out by the merged PRs. | From 95b1ab384fa517c31874b5a021cbe2ea05b08c01 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Wed, 23 Sep 2026 16:30:34 -0400 Subject: [PATCH 3/4] docs(tenancy): index identity reads and coordinated dependencies (#157) --- docs/architecture/tenant-read-leak-matrix.md | 15 ++++++++------- docs/product/backend-delivery-ledger.md | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/architecture/tenant-read-leak-matrix.md b/docs/architecture/tenant-read-leak-matrix.md index 1a819fa..504d93e 100644 --- a/docs/architecture/tenant-read-leak-matrix.md +++ b/docs/architecture/tenant-read-leak-matrix.md @@ -13,14 +13,15 @@ The route inventory below follows `src/Compliance.App/Program.cs`. | Application list and boundary references | `ProjectionReadConsistencyE2ETests.ShouldNeverReturnStaleProjectionOrCrossTenantRowsGivenStandaloneOrSplitHost` covers application-list isolation and boundary-reference exact/non-disclosure in both modes. | Application revision, system-instance and reference list pagination, change preview, and remaining HTTP/MCP permutations. | | Snapshot read, verification and manifest regeneration | `SnapshotE2ETests.ShouldFreezeExactScopeGivenStandaloneOrSplitWorker` covers cross-tenant verification/manifest and outsider denial in both modes. | Two-tenant snapshot list and cursor, plus full MCP list proof. | | Platform metadata exception | `OperatorPortfolioE2ETests.ShouldListPlatformTenantMetadataGivenConfiguredOperatorAndHostMode` covers operator-only tenant metadata pagination in both modes. | Keep this separate from client business-record access. | +| Tenant detail, self-list, operator member list, invitation list, effective member access and matching MCP reads | `TenantIdentityReadLeakMatrixE2ETests.ShouldScopeTenantMemberInvitationAndSelfReadsGivenTwoTenants`: both modes; separate tenant owners and an outsider, operator metadata and member-list authority, HTTP/MCP self-list and invitation cursors through exhaustion, foreign paths and email filters, and foreign member-access denial. [Detailed evidence](tenant-identity-read-leak-matrix.md). | Member-list pagination after a second membership activates; actor snapshots and identity replacement/deprovisioning lifecycle proof. These reads have no total-count field. | -Still open for the same two-tenant, two-host HTTP/MCP matrix: tenant member, -invitation, self-list and access reads; program list, revision and setup-work -reads; control, commitment and risk draft/history pages; client-service tenant -and program lists/history; boundary program lists, versions, decisions and impact -preview; and the remaining application and snapshot reads above. Existing -capability tests often prove standalone cross-tenant denial or split-host -recovery, but that does not prove every list and cursor in both modes. +Still open for the same two-tenant, two-host HTTP/MCP matrix: program list, +revision and setup-work reads; control, commitment and risk draft/history pages; +client-service tenant and program lists/history; boundary program lists, +versions, decisions and impact preview; and the remaining application and +snapshot reads above. Existing capability tests often prove standalone +cross-tenant denial or split-host recovery, but that does not prove every list +and cursor in both modes. The current API has no separate tenant-owned count, export, job, notification or artifact-content route. Import batch status carries row and invalid counts diff --git a/docs/product/backend-delivery-ledger.md b/docs/product/backend-delivery-ledger.md index 41e8be0..9de28f6 100644 --- a/docs/product/backend-delivery-ledger.md +++ b/docs/product/backend-delivery-ledger.md @@ -35,7 +35,7 @@ tracking, not delivered coverage; partial implementations remain open. | Order | Reviewable capability | Backend records and exit gate | | --- | --- | --- | -| 1 | Identity and tenant foundation | Merged [PR #363](https://github.com/bdgrz/compliance/pull/363) and [PR #367](https://github.com/bdgrz/compliance/pull/367) complete SMTP email verification and failed-delivery checkpoint recovery under [AUTH-02a #360](https://github.com/bdgrz/compliance/issues/360). [R1-15 #152](https://github.com/bdgrz/compliance/issues/152), [EN-01 #157](https://github.com/bdgrz/compliance/issues/157), and [R1-01 #158](https://github.com/bdgrz/compliance/issues/158) then complete verified self-service, in-product operator authority, firm-staff denial, tenant authorization, and program scope. [R1-04a–e #183](https://github.com/bdgrz/compliance/issues/183) follow for member activation, scoped grants, teams, deprovisioning, and separation of duties. | +| 1 | Identity and tenant foundation | Merged [PR #363](https://github.com/bdgrz/compliance/pull/363) and [PR #367](https://github.com/bdgrz/compliance/pull/367) complete SMTP email verification and failed-delivery checkpoint recovery under [AUTH-02a #360](https://github.com/bdgrz/compliance/issues/360). Advance [R1-15 #152](https://github.com/bdgrz/compliance/issues/152), [EN-01 #157](https://github.com/bdgrz/compliance/issues/157), [R1-01 #158](https://github.com/bdgrz/compliance/issues/158), and [R1-04a–e #183](https://github.com/bdgrz/compliance/issues/183) as coordinated identity and tenancy work: verified self-service, operator authority, firm-staff denial, tenant authorization, program scope, member activation, grants, teams, deprovisioning, and separation of duties. EN-01 actor replacement and deprovisioning proof consumes #183 lifecycle work; close each child against its own acceptance. | | 2 | Governed R1 source records | Complete version/history and boundary work [#160](https://github.com/bdgrz/compliance/issues/160), [#161](https://github.com/bdgrz/compliance/issues/161), [#162](https://github.com/bdgrz/compliance/issues/162), and [#246](https://github.com/bdgrz/compliance/issues/246), then criteria [#209](https://github.com/bdgrz/compliance/issues/209), application/system instances [#211](https://github.com/bdgrz/compliance/issues/211), manual workforce [#219](https://github.com/bdgrz/compliance/issues/219), technology [#227](https://github.com/bdgrz/compliance/issues/227), commitments [#229](https://github.com/bdgrz/compliance/issues/229), and providers [#231](https://github.com/bdgrz/compliance/issues/231). Use separate `SystemInstance` aggregate ownership from #211. | | 3 | R1 assurance decisions | Finish application scope/change [#215](https://github.com/bdgrz/compliance/issues/215)/[#217](https://github.com/bdgrz/compliance/issues/217), workforce changes/NHI/snapshot [#221](https://github.com/bdgrz/compliance/issues/221)/[#223](https://github.com/bdgrz/compliance/issues/223)/[#225](https://github.com/bdgrz/compliance/issues/225), snapshot enabler [#194](https://github.com/bdgrz/compliance/issues/194), controls [#197](https://github.com/bdgrz/compliance/issues/197)/[#198](https://github.com/bdgrz/compliance/issues/198), risks [#199](https://github.com/bdgrz/compliance/issues/199), and the owned readiness gap plan [#200](https://github.com/bdgrz/compliance/issues/200). Governed artifact storage [#196](https://github.com/bdgrz/compliance/issues/196) needs the external [Portia S3 adapter #69](https://github.com/cntryl/portia/issues/69) before evidence consumers close. | | 4 | R2 operation and review | Deliver the 21 R2 backend children as dependent control/policy/evidence, access-review, work, finding, advisor, and Type I entry capabilities. Start the access population at manually attested [#273](https://github.com/bdgrz/compliance/issues/273); use [F1-07 engagement assignments #269](https://github.com/bdgrz/compliance/issues/269) before advisor access [#275](https://github.com/bdgrz/compliance/issues/275). | From 7f29bf7d6334c4c390a88e06807dc0e6eec51f61 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Wed, 23 Sep 2026 16:45:44 -0400 Subject: [PATCH 4/4] test(tenancy): reject foreign invitation cursors (#157) --- .../tenant-identity-read-leak-matrix.md | 2 +- docs/architecture/tenant-read-leak-matrix.md | 2 +- .../TenantIdentityReadLeakMatrixE2ETests.cs | 32 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/architecture/tenant-identity-read-leak-matrix.md b/docs/architecture/tenant-identity-read-leak-matrix.md index 4c1fc01..241f424 100644 --- a/docs/architecture/tenant-identity-read-leak-matrix.md +++ b/docs/architecture/tenant-identity-read-leak-matrix.md @@ -13,7 +13,7 @@ outside this read matrix. | Tenant detail | Owners see their own tenant; another owner and an outsider receive `NotFound`. A platform operator can read both tenants as the accepted metadata exception. | Other tenant-owned details are inventoried in their own capability matrices. | | My tenants | Each owner sees only their tenant. The outsider and operator see no memberships. Both transports traverse `limit=1` cursors, including empty filtered pages; unauthenticated HTTP is rejected. | This is a membership view, not a tenant inventory for the operator. | | Tenant members | The operator sees only the owner of each tenant. A tenant owner and outsider cannot use the operator-only list. | This fixture has one member per tenant, so it does not prove member-list pagination after a second membership activates. | -| Tenant invitations | Each owner sees only their own two invitation emails while traversing `limit=1` cursors. Foreign tenant paths fail; filtering the owned tenant with the other tenant's email returns an empty page. | Delivery and personal acceptance have separate lifecycle evidence; this scenario does not assert either. | +| Tenant invitations | Each owner sees only their own two invitation emails while traversing `limit=1` cursors. Foreign tenant paths fail; filtering the owned tenant with the other tenant's email returns an empty page. A cursor issued in tenant A is rejected in B with HTTP `BadRequest` and MCP `Validation`. | Delivery and personal acceptance have separate lifecycle evidence. | | Effective member access | Each owner reads their own tenant access. Foreign tenant and foreign user combinations return `NotFound`. | Actor snapshots and later identity replacement/deprovisioning still need lifecycle proof. | These read contracts return items and continuation cursors, without a total diff --git a/docs/architecture/tenant-read-leak-matrix.md b/docs/architecture/tenant-read-leak-matrix.md index 504d93e..56ab202 100644 --- a/docs/architecture/tenant-read-leak-matrix.md +++ b/docs/architecture/tenant-read-leak-matrix.md @@ -13,7 +13,7 @@ The route inventory below follows `src/Compliance.App/Program.cs`. | Application list and boundary references | `ProjectionReadConsistencyE2ETests.ShouldNeverReturnStaleProjectionOrCrossTenantRowsGivenStandaloneOrSplitHost` covers application-list isolation and boundary-reference exact/non-disclosure in both modes. | Application revision, system-instance and reference list pagination, change preview, and remaining HTTP/MCP permutations. | | Snapshot read, verification and manifest regeneration | `SnapshotE2ETests.ShouldFreezeExactScopeGivenStandaloneOrSplitWorker` covers cross-tenant verification/manifest and outsider denial in both modes. | Two-tenant snapshot list and cursor, plus full MCP list proof. | | Platform metadata exception | `OperatorPortfolioE2ETests.ShouldListPlatformTenantMetadataGivenConfiguredOperatorAndHostMode` covers operator-only tenant metadata pagination in both modes. | Keep this separate from client business-record access. | -| Tenant detail, self-list, operator member list, invitation list, effective member access and matching MCP reads | `TenantIdentityReadLeakMatrixE2ETests.ShouldScopeTenantMemberInvitationAndSelfReadsGivenTwoTenants`: both modes; separate tenant owners and an outsider, operator metadata and member-list authority, HTTP/MCP self-list and invitation cursors through exhaustion, foreign paths and email filters, and foreign member-access denial. [Detailed evidence](tenant-identity-read-leak-matrix.md). | Member-list pagination after a second membership activates; actor snapshots and identity replacement/deprovisioning lifecycle proof. These reads have no total-count field. | +| Tenant detail, self-list, operator member list, invitation list, effective member access and matching MCP reads | `TenantIdentityReadLeakMatrixE2ETests.ShouldScopeTenantMemberInvitationAndSelfReadsGivenTwoTenants`: both modes; separate tenant owners and an outsider, operator metadata and member-list authority, HTTP/MCP self-list and invitation cursors through exhaustion, strict foreign invitation cursor rejection, foreign paths and email filters, and foreign member-access denial. [Detailed evidence](tenant-identity-read-leak-matrix.md). | Member-list pagination after a second membership activates and actor replacement/deprovisioning lifecycle proof. These reads have no total-count field. | Still open for the same two-tenant, two-host HTTP/MCP matrix: program list, revision and setup-work reads; control, commitment and risk draft/history pages; diff --git a/test/Compliance.Tests/E2E/TenantIdentityReadLeakMatrixE2ETests.cs b/test/Compliance.Tests/E2E/TenantIdentityReadLeakMatrixE2ETests.cs index f256634..70a996a 100644 --- a/test/Compliance.Tests/E2E/TenantIdentityReadLeakMatrixE2ETests.cs +++ b/test/Compliance.Tests/E2E/TenantIdentityReadLeakMatrixE2ETests.cs @@ -149,6 +149,12 @@ await AssertDeniedHttpAsync(ownerAClient, TenantPath(tenantB) + "/member-invitat TenantPath(tenantB) + "/member-invitations?email_address=" + Uri.EscapeDataString(invitationsA[0])); Assert.Empty(foreignFilter.GetProperty("items").EnumerateArray()); + var httpInvitationCursorA = (await ReadHttpAsync(ownerAClient, + TenantPath(tenantA) + "/member-invitations?limit=1")) + .GetProperty("next_cursor").GetString(); + Assert.NotNull(httpInvitationCursorA); + await AssertForeignHttpInvitationCursorRejectedAsync(ownerBClient, tenantB, + httpInvitationCursorA); var accessA = await ReadHttpAsync(ownerAClient, TenantPath(tenantA) + $"/members/{ownerAId}/access"); @@ -214,6 +220,14 @@ await AssertDeniedHttpAsync(ownerAClient, foreignMcpFilter["email_address"] = invitationsA[0]; Assert.Empty(Ids(await ReadToolAsync(ownerBMcp, "bdgrz.tenant-invitation.list", foreignMcpFilter), "email_address")); + var firstMcpInvitationInput = TenantInput(tenantA); + firstMcpInvitationInput["limit"] = 1; + var mcpInvitationCursorA = (await ReadToolAsync(ownerAMcp, + "bdgrz.tenant-invitation.list", firstMcpInvitationInput)) + .GetProperty("next_cursor").GetString(); + Assert.NotNull(mcpInvitationCursorA); + await AssertForeignMcpInvitationCursorRejectedAsync(ownerBMcp, tenantB, + mcpInvitationCursorA); AssertMemberAccess(await ReadToolAsync(ownerAMcp, "bdgrz.member.access.get", MemberInput(tenantA, ownerAId)), tenantA, ownerAId); @@ -407,6 +421,24 @@ static async Task> ReadMcpInvitationEmailsAsync(McpScenari return emails; } + static async Task AssertForeignHttpInvitationCursorRejectedAsync(HttpClient client, + Uuid tenantId, string cursor) + { + using var response = await client.GetAsync(TenantPath(tenantId) + + "/member-invitations?limit=1&cursor=" + Uri.EscapeDataString(cursor)); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + static async Task AssertForeignMcpInvitationCursorRejectedAsync(McpScenario mcp, + Uuid tenantId, string cursor) + { + var input = TenantInput(tenantId); + input["limit"] = 1; + input["cursor"] = cursor; + _ = await mcp.When("bdgrz.tenant-invitation.list", input) + .ExpectFailure("Validation"); + } + static async Task ReadIdsAsync(HttpClient client, string path, string field) => Ids(await ReadHttpAsync(client, path), field);